From a4d4598a139e90a99133d56b1006042f3ee25312 Mon Sep 17 00:00:00 2001 From: Alex X Date: Sun, 14 Dec 2025 13:07:45 +0300 Subject: [PATCH] Add support xiaomi source --- internal/xiaomi/README.md | 50 ++++ internal/xiaomi/xiaomi.go | 267 ++++++++++++++++++ main.go | 2 + pkg/core/helpers.go | 15 + pkg/xiaomi/backchannel.go | 42 +++ pkg/xiaomi/cloud.go | 563 ++++++++++++++++++++++++++++++++++++++ pkg/xiaomi/miss/client.go | 451 ++++++++++++++++++++++++++++++ pkg/xiaomi/producer.go | 206 ++++++++++++++ www/add.html | 82 ++++++ 9 files changed, 1678 insertions(+) create mode 100644 internal/xiaomi/README.md create mode 100644 internal/xiaomi/xiaomi.go create mode 100644 pkg/xiaomi/backchannel.go create mode 100644 pkg/xiaomi/cloud.go create mode 100644 pkg/xiaomi/miss/client.go create mode 100644 pkg/xiaomi/producer.go diff --git a/internal/xiaomi/README.md b/internal/xiaomi/README.md new file mode 100644 index 00000000..80d98beb --- /dev/null +++ b/internal/xiaomi/README.md @@ -0,0 +1,50 @@ +# Xiaomi + +This source allows you to view cameras from the [Xiaomi Mi Home](https://home.mi.com/) ecosystem. + +**Important:** + +1. **Not all cameras are supported**. There are several P2P protocol vendors in the Xiaomi ecosystem. +Currently, the **CS2** vendor is supported. However, the **TUTK** vendor is not supported. +2. Each time you connect to the camera, you need internet access to obtain encryption keys. +3. Connection to the camera is local only. + +**Features:** + +- Multiple Xiaomi accounts supported +- Cameras from multiple regions are supported for a single account +- Two-way audio is supported +- Cameras with multiple lenses are supported + +## Setup + +1. Goto go2rtc WebUI > Add > Xiaomi > Login with username and password +2. Receive verification code by email or phone if required. +3. Complete the captcha if required. +4. If everything is OK, your account will be added and you can load cameras from it. + +**Example** + +```yaml +xiaomi: + 1234567890: V1:*** + +streams: + xiaomi1: xiaomi://1234567890:cn@192.168.1.123?did=9876543210&model=isa.camera.hlc7 +``` + +## Configuration + +You can change camera's quality: `subtype=hd/sd/auto` + +```yaml +streams: + xiaomi1: xiaomi://***&subtype=sd +``` + +You can use second channel for Dual cameras: `channel=1` + +```yaml +streams: + xiaomi1: xiaomi://***&channel=1 +``` diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go new file mode 100644 index 00000000..39c15460 --- /dev/null +++ b/internal/xiaomi/xiaomi.go @@ -0,0 +1,267 @@ +package xiaomi + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/xiaomi" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" +) + +func Init() { + var v struct { + Cfg map[string]string `yaml:"xiaomi"` + } + app.LoadConfig(&v) + + tokens = v.Cfg + + log := app.GetLogger("xiaomi") + + streams.HandleFunc("xiaomi", func(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + if u.User != nil { + rawURL, err = getCameraURL(u) + if err != nil { + return nil, err + } + } + + log.Debug().Msgf("xiaomi: dial %s", rawURL) + + return xiaomi.Dial(rawURL) + }) + + api.HandleFunc("api/xiaomi", apiXiaomi) +} + +var tokens map[string]string +var tokensMu sync.Mutex + +func getCloud(userID string) (*xiaomi.Cloud, error) { + tokensMu.Lock() + defer tokensMu.Unlock() + + token := tokens[userID] + cloud := xiaomi.NewCloud(AppXiaomiHome) + if err := cloud.LoginWithToken(userID, token); err != nil { + return nil, err + } + + return cloud, nil +} + +func getCameraURL(url *url.URL) (string, error) { + clientPublic, clientPrivate, err := miss.GenerateKey() + if err != nil { + return "", err + } + + query := url.Query() + + params := fmt.Sprintf( + `{"app_pubkey":"%x","did":"%s","support_vendors":"CS2"}`, + clientPublic, query.Get("did"), + ) + + cloud, err := getCloud(url.User.Username()) + if err != nil { + return "", err + } + + region, _ := url.User.Password() + + res, err := cloud.Request(GetBaseURL(region), "/v2/device/miss_get_vendor", params, nil) + if err != nil { + return "", err + } + + var v struct { + Vendor struct { + VendorID byte `json:"vendor"` + } `json:"vendor"` + PublicKey string `json:"public_key"` + Sign string `json:"sign"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + query.Set("client_public", hex.EncodeToString(clientPublic)) + query.Set("client_private", hex.EncodeToString(clientPrivate)) + query.Set("device_public", v.PublicKey) + query.Set("sign", v.Sign) + query.Set("vendor", getVendorName(v.Vendor.VendorID)) + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getVendorName(i byte) string { + switch i { + case 1: + return "tutk" + case 3: + return "agora" + case 4: + return "cs2" + case 6: + return "mtp" + } + return fmt.Sprintf("%d", i) +} + +func apiXiaomi(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + apiDeviceList(w, r) + case "POST": + apiAuth(w, r) + } +} + +func apiDeviceList(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + user := query.Get("id") + if user == "" { + tokensMu.Lock() + users := make([]string, 0, len(tokens)) + for s := range tokens { + users = append(users, s) + } + tokensMu.Unlock() + + api.ResponseJSON(w, users) + return + } + + err := func() error { + cloud, err := getCloud(user) + if err != nil { + return err + } + + region := query.Get("region") + + res, err := cloud.Request(GetBaseURL(region), "/v2/home/device_list_page", "{}", nil) + if err != nil { + return err + } + var v struct { + List []*Device `json:"list"` + } + + if err = json.Unmarshal(res, &v); err != nil { + return err + } + + var items []*api.Source + + for _, device := range v.List { + if !strings.Contains(device.Model, ".camera.") { + continue + } + items = append(items, &api.Source{ + Name: device.Name, + Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC), + URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s", user, region, device.IP, device.Did, device.Model), + }) + } + + api.ResponseSources(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +type Device struct { + Did string `json:"did"` + Name string `json:"name"` + Model string `json:"model"` + MAC string `json:"mac"` + IP string `json:"localip"` +} + +var auth *xiaomi.Cloud + +func apiAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + username := r.Form.Get("username") + password := r.Form.Get("password") + captcha := r.Form.Get("captcha") + verify := r.Form.Get("verify") + + var err error + + switch { + case username != "" || password != "": + auth = xiaomi.NewCloud(AppXiaomiHome) + err = auth.Login(username, password) + case captcha != "": + err = auth.LoginWithCaptcha(captcha) + case verify != "": + err = auth.LoginWithVerify(verify) + default: + http.Error(w, "wrong request", http.StatusBadRequest) + return + } + + if err == nil { + userID, token := auth.UserToken() + auth = nil + + tokensMu.Lock() + if tokens == nil { + tokens = map[string]string{userID: token} + } else { + tokens[userID] = token + } + tokensMu.Unlock() + + err = app.PatchConfig([]string{"xiaomi", userID}, token) + } + + if err != nil { + var login *xiaomi.LoginError + if errors.As(err, &login) { + w.Header().Set("Content-Type", api.MimeJSON) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(err) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +const AppXiaomiHome = "xiaomiio" + +func GetBaseURL(region string) string { + switch region { + case "de", "i2", "ru", "sg", "us": + return "https://" + region + ".api.io.mi.com/app" + } + return "https://api.io.mi.com/app" +} diff --git a/main.go b/main.go index bdf94a6a..b3d470a5 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webtorrent" "github.com/AlexxIT/go2rtc/internal/wyoming" + "github.com/AlexxIT/go2rtc/internal/xiaomi" "github.com/AlexxIT/go2rtc/internal/yandex" "github.com/AlexxIT/go2rtc/pkg/shell" ) @@ -98,6 +99,7 @@ func main() { {"roborock", roborock.Init}, {"tapo", tapo.Init}, {"tuya", tuya.Init}, + {"xiaomi", xiaomi.Init}, {"yandex", yandex.Init}, // Helper modules {"debug", debug.Init}, diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 6935e7f2..52b969a7 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -67,6 +67,21 @@ func Atoi(s string) (i int) { return } +// ParseByte - fast parsing string to byte function +func ParseByte(s string) (b byte) { + for i, ch := range []byte(s) { + ch -= '0' + if ch > 9 { + return 0 + } + if i > 0 { + b *= 10 + } + b += ch + } + return +} + func Assert(ok bool) { if !ok { _, file, line, _ := runtime.Caller(1) diff --git a/pkg/xiaomi/backchannel.go b/pkg/xiaomi/backchannel.go new file mode 100644 index 00000000..3e1b0f4c --- /dev/null +++ b/pkg/xiaomi/backchannel.go @@ -0,0 +1,42 @@ +package xiaomi + +import ( + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" + "github.com/pion/rtp" +) + +const size8bit40ms = 8000 * 0.040 + +func (p *Producer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + if err := p.client.SpeakerStart(); err != nil { + return err + } + // TODO: check this!!! + time.Sleep(time.Second) + + sender := core.NewSender(media, track.Codec) + + switch track.Codec.Name { + case core.CodecPCMA: + var buf []byte + + sender.Handler = func(pkt *rtp.Packet) { + buf = append(buf, pkt.Payload...) + for len(buf) >= size8bit40ms { + _ = p.client.WriteAudio(miss.CodecPCMA, buf[:size8bit40ms]) + buf = buf[size8bit40ms:] + } + } + case core.CodecOpus: + sender.Handler = func(pkt *rtp.Packet) { + _ = p.client.WriteAudio(miss.CodecOPUS, pkt.Payload) + } + } + + sender.HandleRTP(track) + p.Senders = append(p.Senders, sender) + return nil +} diff --git a/pkg/xiaomi/cloud.go b/pkg/xiaomi/cloud.go new file mode 100644 index 00000000..5e1e73cc --- /dev/null +++ b/pkg/xiaomi/cloud.go @@ -0,0 +1,563 @@ +package xiaomi + +import ( + "bytes" + "crypto/md5" + "crypto/rand" + "crypto/rc4" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Cloud struct { + client *http.Client + + sid string + cookies string // for auth + ssecurity []byte // for encryption + + userID string + passToken string + + auth map[string]string +} + +func NewCloud(sid string) *Cloud { + return &Cloud{ + client: &http.Client{Timeout: 15 * time.Second}, + sid: sid, + } +} + +func (c *Cloud) Login(username, password string) error { + res, err := c.client.Get("https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=" + c.sid) + if err != nil { + return err + } + + var v1 struct { + Qs string `json:"qs"` + Sign string `json:"_sign"` + Sid string `json:"sid"` + Callback string `json:"callback"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + hash := fmt.Sprintf("%X", md5.Sum([]byte(password))) + + form := url.Values{ + "_json": {"true"}, + "hash": {hash}, + "sid": {v1.Sid}, + "callback": {v1.Callback}, + "_sign": {v1.Sign}, + "qs": {v1.Qs}, + "user": {username}, + } + cookies := "deviceId=" + core.RandString(16, 62) + + // login after captcha + if c.auth != nil && c.auth["captcha_code"] != "" { + form.Set("captCode", c.auth["captcha_code"]) + cookies += "; ick=" + c.auth["ick"] + } + + req := Request{ + Method: "POST", + URL: "https://account.xiaomi.com/pass/serviceLoginAuth2", + RawBody: form.Encode(), + Headers: url.Values{ + "Content-Type": {"application/x-www-form-urlencoded"}, + }, + RawCookies: cookies, + }.Encode() + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + + CaptchaURL string `json:"captchaURL"` + NotificationURL string `json:"notificationUrl"` + } + body, err := readLoginResponse(res.Body, &v2) + if err != nil { + return err + } + + // save auth for two step verification + c.auth = map[string]string{ + "username": username, + "password": password, + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.NotificationURL != "" { + return c.authStart(v2.NotificationURL) + } + + if v2.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + c.auth = nil + c.ssecurity = v2.Ssecurity + c.passToken = v2.PassToken + + return c.finishAuth(v2.Location) +} + +func (c *Cloud) LoginWithCaptcha(captcha string) error { + if c.auth == nil || c.auth["ick"] == "" { + panic("wrong login step") + } + + c.auth["captcha_code"] = captcha + + // check if captcha after verify + if c.auth["flag"] != "" { + return c.sendTicket() + } + + return c.Login(c.auth["username"], c.auth["password"]) +} + +func (c *Cloud) LoginWithVerify(ticket string) error { + if c.auth == nil || c.auth["flag"] == "" { + panic("wrong login step") + } + + req := Request{ + Method: "POST", + URL: "https://account.xiaomi.com/identity/auth/verify" + c.verifyName(), + RawParams: "_flag" + c.auth["flag"] + "&ticket=" + ticket + "&trust=false&_json=true", + RawCookies: "identity_session=" + c.auth["identity_session"], + }.Encode() + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Location string `json:"location"` + } + body, err := readLoginResponse(res.Body, &v1) + if err != nil { + return err + } + if v1.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + return c.finishAuth(v1.Location) +} + +func (c *Cloud) getCaptcha(captchaURL string) error { + res, err := c.client.Get("https://account.xiaomi.com" + captchaURL) + if err != nil { + return err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + c.auth["ick"] = findCookie(res, "ick") + + return &LoginError{ + Captcha: body, + } +} + +func (c *Cloud) authStart(notificationURL string) error { + rawURL := strings.Replace(notificationURL, "/fe/service/identity/authStart", "/identity/list", 1) + res, err := c.client.Get(rawURL) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + Flag int `json:"flag"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + c.auth["flag"] = strconv.Itoa(v1.Flag) + c.auth["identity_session"] = findCookie(res, "identity_session") + + return c.sendTicket() +} + +func findCookie(res *http.Response, name string) string { + for _, cookie := range res.Cookies() { + if cookie.Name == name { + return cookie.Value + } + } + return "" +} + +func (c *Cloud) verifyName() string { + switch c.auth["flag"] { + case "4": + return "Phone" + case "8": + return "Email" + } + return "" +} + +func (c *Cloud) sendTicket() error { + name := c.verifyName() + cookies := "identity_session=" + c.auth["identity_session"] + + req := Request{ + URL: "https://account.xiaomi.com/identity/auth/verify" + name, + RawParams: "_flag=" + c.auth["flag"] + "&_json=true", + RawCookies: cookies, + }.Encode() + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + MaskedPhone string `json:"maskedPhone"` + MaskedEmail string `json:"maskedEmail"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + // verify after captcha + captCode := c.auth["captcha_code"] + if captCode != "" { + cookies += "; ick=" + c.auth["ick"] + } + + req = Request{ + Method: "POST", + URL: "https://account.xiaomi.com/identity/auth/send" + name + "Ticket", + RawCookies: cookies, + RawBody: `{"retry":0,"icode":"` + captCode + `","_json":"true"}`, + }.Encode() + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Code int `json:"code"` + CaptchaURL string `json:"captchaURL"` + } + body, err := readLoginResponse(res.Body, &v2) + if err != nil { + return err + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.Code != 0 { + return fmt.Errorf("xiaomi: %s", body) + } + + return &LoginError{ + VerifyPhone: v1.MaskedPhone, + VerifyEmail: v1.MaskedEmail, + } +} + +type LoginError struct { + Captcha []byte `json:"captcha,omitempty"` + VerifyPhone string `json:"verify_phone,omitempty"` + VerifyEmail string `json:"verify_email,omitempty"` +} + +func (l *LoginError) Error() string { + return "" +} + +func (c *Cloud) finishAuth(location string) error { + res, err := c.client.Get(location) + if err != nil { + return err + } + defer res.Body.Close() + + // LoginWithVerify + // - userId, cUserId, serviceToken from cookies + // - passToken from redirect cookies + // - ssecurity from extra header + // LoginWithToken + // - userId, cUserId, serviceToken from cookies + var cUserId, serviceToken string + + for res != nil { + for _, cookie := range res.Cookies() { + switch cookie.Name { + case "userId": + c.userID = cookie.Value + case "cUserId": + cUserId = cookie.Value + case "serviceToken": + serviceToken = cookie.Value + case "passToken": + c.passToken = cookie.Value + } + } + + if s := res.Header.Get("Extension-Pragma"); s != "" { + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + } + if err = json.Unmarshal([]byte(s), &v1); err != nil { + return err + } + c.ssecurity = v1.Ssecurity + } + + res = res.Request.Response + } + + c.cookies = fmt.Sprintf("userId=%s; cUserId=%s; serviceToken=%s", c.userID, cUserId, serviceToken) + + return nil +} + +func (c *Cloud) LoginWithToken(userID, passToken string) error { + req, err := http.NewRequest("GET", "https://account.xiaomi.com/pass/serviceLogin?_json=true&sid="+c.sid, nil) + if err != nil { + return err + } + + req.Header.Set("Cookie", fmt.Sprintf("userId=%s; passToken=%s", userID, passToken)) + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + } + if _, err = readLoginResponse(res.Body, &v1); err != nil { + return err + } + + c.ssecurity = v1.Ssecurity + c.passToken = v1.PassToken + + return c.finishAuth(v1.Location) +} + +func (c *Cloud) UserToken() (string, string) { + return c.userID, c.passToken +} + +func (c *Cloud) Request(baseURL, apiURL, params string, headers map[string]string) ([]byte, error) { + form := url.Values{"data": {params}} + + nonce := genNonce() + signedNonce := genSignedNonce(c.ssecurity, nonce) + + // 1. gen hash for data param + form.Set("rc4_hash__", genSignature64("POST", apiURL, form, signedNonce)) + + // 2. encrypt data and hash params + for _, v := range form { + ciphertext, err := crypt(signedNonce, []byte(v[0])) + if err != nil { + return nil, err + } + v[0] = base64.StdEncoding.EncodeToString(ciphertext) + } + + // 3. add signature for encrypted data and hash params + form.Set("signature", genSignature64("POST", apiURL, form, signedNonce)) + + // 4. add nonce + form.Set("_nonce", base64.StdEncoding.EncodeToString(nonce)) + + req, err := http.NewRequest("POST", baseURL+apiURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Cookie", c.cookies) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + for k, v := range headers { + req.Header.Set(k, v) + } + + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, errors.New(res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + ciphertext, err := base64.StdEncoding.DecodeString(string(body)) + if err != nil { + return nil, err + } + + plaintext, err := crypt(signedNonce, ciphertext) + if err != nil { + return nil, err + } + + var res1 struct { + Code int `json:"code"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + if err = json.Unmarshal(plaintext, &res1); err != nil { + return nil, err + } + + if res1.Code != 0 { + return nil, errors.New("xiaomi: " + res1.Message) + } + + return res1.Result, nil +} + +func readLoginResponse(rc io.ReadCloser, v any) ([]byte, error) { + defer rc.Close() + + body, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + body, ok := bytes.CutPrefix(body, []byte("&&&START&&&")) + if !ok { + return nil, fmt.Errorf("xiaomi: %s", body) + } + + return body, json.Unmarshal(body, &v) +} + +func genNonce() []byte { + ts := time.Now().Unix() / 60 + + nonce := make([]byte, 12) + _, _ = rand.Read(nonce[:8]) + binary.BigEndian.PutUint32(nonce[8:], uint32(ts)) + return nonce +} + +func genSignedNonce(ssecurity, nonce []byte) []byte { + hasher := sha256.New() + hasher.Write(ssecurity) + hasher.Write(nonce) + return hasher.Sum(nil) +} + +func crypt(key, plaintext []byte) ([]byte, error) { + cipher, err := rc4.NewCipher(key) + if err != nil { + return nil, err + } + + tmp := make([]byte, 1024) + cipher.XORKeyStream(tmp, tmp) + + ciphertext := make([]byte, len(plaintext)) + cipher.XORKeyStream(ciphertext, plaintext) + + return ciphertext, nil +} + +func genSignature64(method, path string, values url.Values, signedNonce []byte) string { + s := method + "&" + path + "&data=" + values.Get("data") + if values.Has("rc4_hash__") { + s += "&rc4_hash__=" + values.Get("rc4_hash__") + } + s += "&" + base64.StdEncoding.EncodeToString(signedNonce) + + hasher := sha1.New() + hasher.Write([]byte(s)) + signature := hasher.Sum(nil) + + return base64.StdEncoding.EncodeToString(signature) +} + +type Request struct { + Method string + URL string + RawParams string + RawBody string + Headers url.Values + RawCookies string +} + +func (r Request) Encode() *http.Request { + if r.RawParams != "" { + r.URL += "?" + r.RawParams + } + + var body io.Reader + if r.RawBody != "" { + body = strings.NewReader(r.RawBody) + } + + req, err := http.NewRequest(r.Method, r.URL, body) + if err != nil { + return nil + } + + if r.Headers != nil { + req.Header = http.Header(r.Headers) + } + + if r.RawCookies != "" { + req.Header.Set("Cookie", r.RawCookies) + } + + return req +} diff --git a/pkg/xiaomi/miss/client.go b/pkg/xiaomi/miss/client.go new file mode 100644 index 00000000..49def2ae --- /dev/null +++ b/pkg/xiaomi/miss/client.go @@ -0,0 +1,451 @@ +package miss + +import ( + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "log" + "net" + "net/url" + "strings" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "golang.org/x/crypto/chacha20" + "golang.org/x/crypto/nacl/box" +) + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + if query.Get("vendor") != "cs2" { + return nil, fmt.Errorf("miss: unsupported vendor") + } + + clientPrivate := query.Get("client_private") + devicePublic := query.Get("device_public") + + key, err := calcSharedKey(devicePublic, clientPrivate) + if err != nil { + return nil, err + } + + conn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + + client := &Client{ + conn: conn, + addr: &net.UDPAddr{IP: net.ParseIP(u.Host), Port: 32108}, + buf: make([]byte, 1500), + key: key, + } + + clientPublic := query.Get("client_public") + sign := query.Get("sign") + + if err = client.login(clientPublic, sign); err != nil { + _ = conn.Close() + return nil, err + } + + client.chSeq0 = 1 + client.chRaw2 = make(chan []byte, 100) + go client.worker() + + return client, nil +} + +const ( + CodecH264 = 4 + CodecH265 = 5 + CodecPCM = 1024 + CodecPCMU = 1026 + CodecPCMA = 1027 + CodecOPUS = 1032 +) + +type Client struct { + conn *net.UDPConn + addr *net.UDPAddr + buf []byte + key []byte // shared key + + chSeq0 uint16 + chSeq3 uint16 + chRaw2 chan []byte +} + +func (c *Client) RemoteAddr() *net.UDPAddr { + return c.addr +} + +func (c *Client) SetDeadline(t time.Time) error { + return c.conn.SetDeadline(t) +} + +func (c *Client) Close() error { + return c.conn.Close() +} + +const ( + magic = 0xF1 + magicDrw = 0xD1 + msgLanSearch = 0x30 + msgPunchPkt = 0x41 + msgP2PRdy = 0x42 + msgDrw = 0xD0 + msgDrwAck = 0xD1 + msgAlive = 0xE0 + + cmdAuthReq = 0x100 + cmdAuthRes = 0x101 + cmdVideoStart = 0x102 + cmdVideoStop = 0x103 + cmdAudioStart = 0x104 + cmdAudioStop = 0x105 + cmdSpeakerStartReq = 0x106 + cmdSpeakerStartRes = 0x107 + cmdSpeakerStop = 0x108 + cmdStreamCtrlReq = 0x109 + cmdStreamCtrlRes = 0x10A + cmdGetAudioFormatReq = 0x10B + cmdGetAudioFormatRes = 0x10C + cmdPlaybackReq = 0x10D + cmdPlaybackRes = 0x10E + cmdDevInfoReq = 0x110 + cmdDevInfoRes = 0x111 + cmdMotorReq = 0x112 + cmdMotorRes = 0x113 + cmdEncoded = 0x1001 +) + +func (c *Client) login(clientPublic, sign string) error { + _ = c.conn.SetDeadline(time.Now().Add(core.ConnDialTimeout)) + + buf, err := c.writeAndWait([]byte{magic, msgLanSearch, 0, 0}, msgPunchPkt) + if err != nil { + return fmt.Errorf("miss: read punch: %w", err) + } + + _, err = c.writeAndWait(buf, msgP2PRdy) + if err != nil { + return fmt.Errorf("miss: read ready: %w", err) + } + + _, _ = c.conn.WriteToUDP([]byte{magic, msgAlive, 0, 0}, c.addr) + + s := fmt.Sprintf(`{"public_key":"%s","sign":"%s","uuid":"","support_encrypt":0}`, clientPublic, sign) + buf, err = c.writeAndWait(marshalCmd(0, 0, cmdAuthReq, []byte(s)), msgDrw) + if err != nil { + return fmt.Errorf("miss: read auth: %w", err) + } + + if !strings.Contains(string(buf[16:]), `"result":"success"`) { + return fmt.Errorf("miss: read auth: %s", buf[16:]) + } + + _, _ = c.conn.WriteToUDP([]byte{magic, msgDrwAck, 0, 6, magicDrw, 0, 0, 1, 0, 0}, c.addr) + + _ = c.conn.SetDeadline(time.Time{}) + + return nil +} + +func (c *Client) writeAndWait(b []byte, waitMsg uint8) ([]byte, error) { + if _, err := c.conn.WriteToUDP(b, c.addr); err != nil { + return nil, err + } + + for { + n, addr, err := c.conn.ReadFromUDP(c.buf) + if err != nil { + return nil, err + } + + if string(addr.IP) != string(c.addr.IP) { + continue // skip messages from another IP + } + + if n >= 16 && c.buf[0] == magic && c.buf[1] == waitMsg { + if waitMsg == msgPunchPkt { + c.addr.Port = addr.Port + } + return c.buf[:n], nil + } + } +} + +func (c *Client) VideoStart(channel, quality, audio uint8) error { + buf := binary.BigEndian.AppendUint32(nil, cmdVideoStart) + if channel == 0 { + buf = fmt.Appendf(buf, `{"videoquality":%d,"enableaudio":%d}`, quality, audio) + } else { + buf = fmt.Appendf(buf, `{"videoquality":-1,"videoquality2":%d,"enableaudio":%d}`, quality, audio) + } + buf, err := encode(c.key, buf) + if err != nil { + return err + } + buf = marshalCmd(0, c.chSeq0, cmdEncoded, buf) + c.chSeq0++ + + _, err = c.conn.WriteToUDP(buf, c.addr) + return err +} + +func (c *Client) SpeakerStart() error { + buf := binary.BigEndian.AppendUint32(nil, cmdSpeakerStartReq) + buf, err := encode(c.key, buf) + if err != nil { + return err + } + buf = marshalCmd(0, c.chSeq0, cmdEncoded, buf) + c.chSeq0++ + + _, err = c.conn.WriteToUDP(buf, c.addr) + return err +} + +func (c *Client) ReadPacket() (*Packet, error) { + b, ok := <-c.chRaw2 + if !ok { + return nil, fmt.Errorf("miss: read raw: i/o timeout") + } + return unmarshalPacket(c.key, b) +} + +func unmarshalPacket(key, b []byte) (*Packet, error) { + n := uint32(len(b)) + + if n < 32 { + return nil, fmt.Errorf("miss: packet header too small") + } + + if l := binary.LittleEndian.Uint32(b); l+32 != n { + return nil, fmt.Errorf("miss: packet payload has wrong length") + } + + payload, err := decode(key, b[32:]) + if err != nil { + return nil, err + } + + return &Packet{ + CodecID: binary.LittleEndian.Uint32(b[4:]), + Sequence: binary.LittleEndian.Uint32(b[8:]), + Flags: binary.LittleEndian.Uint32(b[12:]), + Timestamp: binary.LittleEndian.Uint64(b[16:]), + Payload: payload, + }, nil +} + +func (c *Client) WriteAudio(codecID uint32, payload []byte) error { + payload, err := encode(c.key, payload) + if err != nil { + return err + } + + n := uint32(len(payload)) + + const hdrOffset = 12 + const hdrSize = 32 + + buf := make([]byte, n+hdrOffset+hdrSize) + buf[0] = magic + buf[1] = msgDrw + binary.BigEndian.PutUint16(buf[2:], uint16(n+8+hdrSize)) + + buf[4] = magicDrw + buf[5] = 3 // channel + binary.BigEndian.PutUint16(buf[6:], c.chSeq3) + + binary.BigEndian.PutUint32(buf[8:], n+hdrSize) + + binary.LittleEndian.PutUint32(buf[hdrOffset:], n) + binary.LittleEndian.PutUint32(buf[hdrOffset+4:], codecID) + binary.LittleEndian.PutUint64(buf[hdrOffset+16:], uint64(time.Now().UnixMilli())) + copy(buf[hdrOffset+hdrSize:], payload) + + c.chSeq3++ + + _, err = c.conn.WriteToUDP(buf, c.addr) + return err +} + +func (c *Client) worker() { + defer close(c.chRaw2) + + chAck := []uint16{1, 0, 0, 0} + + var ch2WaitSize int + var ch2WaitData []byte + + for { + n, addr, err := c.conn.ReadFromUDP(c.buf) + if err != nil { + return + } + + //log.Printf("<- %.20x...", c.buf[:n]) + + if string(addr.IP) != string(c.addr.IP) || n < 8 || c.buf[0] != magic { + //log.Printf("unknown msg: %x", c.buf[:n]) + continue // skip messages from another IP + } + + switch c.buf[1] { + case msgDrw: + ch := c.buf[5] + seqHI := c.buf[6] + seqLO := c.buf[7] + + if chAck[ch] != uint16(seqHI)<<8|uint16(seqLO) { + continue + } + chAck[ch]++ + + //log.Printf("%.40x", c.buf) + + ack := []byte{magic, msgDrwAck, 0, 6, magicDrw, ch, 0, 1, seqHI, seqLO} + if _, err = c.conn.WriteToUDP(ack, c.addr); err != nil { + return + } + + switch ch { + case 0: + //log.Printf("data ch0 %x", c.buf[:n]) + //size := binary.BigEndian.Uint32(c.buf[8:]) + //if binary.BigEndian.Uint32(c.buf[12:]) == cmdEncoded { + // raw, _ := decode(c.key, c.buf[16:12+size]) + // log.Printf("cmd enc %x", raw) + //} else { + // log.Printf("cmd raw %x", c.buf[12:12+size]) + //} + + case 2: + ch2WaitData = append(ch2WaitData, c.buf[8:n]...) + + for len(ch2WaitData) > 4 { + if ch2WaitSize == 0 { + ch2WaitSize = int(binary.BigEndian.Uint32(ch2WaitData)) + ch2WaitData = ch2WaitData[4:] + } + if ch2WaitSize <= len(ch2WaitData) { + c.chRaw2 <- ch2WaitData[:ch2WaitSize] + ch2WaitData = ch2WaitData[ch2WaitSize:] + ch2WaitSize = 0 + } else { + break + } + } + + default: + log.Printf("!!! unknown chanel: %x", c.buf[:n]) + } + + case msgDrwAck: // skip it + + default: + log.Printf("!!! unknown msg type: %x", c.buf[:n]) + } + } +} + +func marshalCmd(channel byte, seq uint16, cmd uint32, payload []byte) []byte { + size := len(payload) + buf := make([]byte, 4+4+4+4+size) + + // 1. message header (4 bytes) + buf[0] = magic + buf[1] = msgDrw + binary.BigEndian.PutUint16(buf[2:], uint16(4+4+4+size)) + + // 2. drw? header (4 bytes) + buf[4] = magicDrw + buf[5] = channel + binary.BigEndian.PutUint16(buf[6:], seq) + + // 3. payload size (4 bytes) + binary.BigEndian.PutUint32(buf[8:], uint32(4+size)) + + // 4. payload command (4 bytes) + binary.BigEndian.PutUint32(buf[12:], cmd) + + // 5. payload + copy(buf[16:], payload) + + return buf +} + +func calcSharedKey(devicePublic, clientPrivate string) ([]byte, error) { + var sharedKey, publicKey, privateKey [32]byte + if _, err := hex.Decode(publicKey[:], []byte(devicePublic)); err != nil { + return nil, err + } + if _, err := hex.Decode(privateKey[:], []byte(clientPrivate)); err != nil { + return nil, err + } + box.Precompute(&sharedKey, &publicKey, &privateKey) + return sharedKey[:], nil +} + +func encode(key, src []byte) ([]byte, error) { + dst := make([]byte, len(src)+8) + + if _, err := rand.Read(dst[:8]); err != nil { + return nil, err + } + + nonce := make([]byte, 12) + copy(nonce[4:], dst[:8]) + + c, err := chacha20.NewUnauthenticatedCipher(key, nonce) + if err != nil { + return nil, err + } + + c.XORKeyStream(dst[8:], src) + + return dst, nil +} + +func decode(key, src []byte) ([]byte, error) { + nonce := make([]byte, 12) + copy(nonce[4:], src[:8]) + + c, err := chacha20.NewUnauthenticatedCipher(key, nonce) + if err != nil { + return nil, err + } + + dst := make([]byte, len(src)-8) + c.XORKeyStream(dst, src[8:]) + + return dst, nil +} + +type Packet struct { + //Length uint32 + CodecID uint32 + Sequence uint32 + Flags uint32 + Timestamp uint64 // msec + //TimestampS uint32 + //Reserved uint32 + Payload []byte +} + +func GenerateKey() ([]byte, []byte, error) { + public, private, err := box.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, err + } + return public[:], private[:], err +} diff --git a/pkg/xiaomi/producer.go b/pkg/xiaomi/producer.go new file mode 100644 index 00000000..6a7537e4 --- /dev/null +++ b/pkg/xiaomi/producer.go @@ -0,0 +1,206 @@ +package xiaomi + +import ( + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/miss" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + client *miss.Client +} + +func Dial(rawURL string) (core.Producer, error) { + client, err := miss.Dial(rawURL) + if err != nil { + return nil, err + } + + u, _ := url.Parse(rawURL) + query := u.Query() + + // 0 - main, 1 - second + channel := core.ParseByte(query.Get("channel")) + + // 0 - auto, 1 - worst, 3 or 5 - best + var quality byte + switch s := query.Get("subtype"); s { + case "", "hd": + quality = 3 + case "sd": + quality = 1 + case "auto": + quality = 0 + default: + quality = core.ParseByte(s) + } + + medias, err := probe(client, channel, quality) + if err != nil { + _ = client.Close() + return nil, err + } + + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "xiaomi", + Protocol: "cs2+udp", + RemoteAddr: client.RemoteAddr().String(), + Source: rawURL, + Medias: medias, + Transport: client, + }, + client: client, + }, nil +} + +func probe(client *miss.Client, channel, quality uint8) ([]*core.Media, error) { + _ = client.SetDeadline(time.Now().Add(core.ProbeTimeout)) + + if err := client.VideoStart(channel, quality, 1); err != nil { + return nil, err + } + + var video, audio *core.Codec + + for { + pkt, err := client.ReadPacket() + if err != nil { + return nil, fmt.Errorf("xiaomi: probe: %w", err) + } + + switch pkt.CodecID { + case miss.CodecH264: + if video == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if h264.NALUType(buf) == h264.NALUTypeSPS { + video = h264.AVCCToCodec(buf) + } + } + case miss.CodecH265: + if video == nil { + buf := annexb.EncodeToAVCC(pkt.Payload) + if h265.NALUType(buf) == h265.NALUTypeVPS { + video = h265.AVCCToCodec(buf) + } + } + case miss.CodecPCMA: + if audio == nil { + audio = &core.Codec{Name: core.CodecPCMA, ClockRate: 8000} + } + case miss.CodecOPUS: + if audio == nil { + audio = &core.Codec{Name: core.CodecOpus, ClockRate: 48000, Channels: 2} + } + } + + if video != nil && audio != nil { + break + } + } + + _ = client.SetDeadline(time.Time{}) + + return []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{video}, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{audio}, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{audio.Clone()}, + }, + }, nil +} + +const timestamp40ms = 48000 * 0.040 + +func (p *Producer) Start() error { + var audioTS uint32 + + for { + _ = p.client.SetDeadline(time.Now().Add(core.ConnDeadline)) + pkt, err := p.client.ReadPacket() + if err != nil { + return err + } + + // TODO: rewrite this + var name string + var pkt2 *core.Packet + + switch pkt.CodecID { + case miss.CodecH264: + name = core.CodecH264 + pkt2 = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: uint16(pkt.Sequence), + Timestamp: TimeToRTP(pkt.Timestamp, 90000), + }, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + case miss.CodecH265: + name = core.CodecH265 + pkt2 = &core.Packet{ + Header: rtp.Header{ + SequenceNumber: uint16(pkt.Sequence), + Timestamp: TimeToRTP(pkt.Timestamp, 90000), + }, + Payload: annexb.EncodeToAVCC(pkt.Payload), + } + case miss.CodecPCMA: + name = core.CodecPCMA + pkt2 = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: uint16(pkt.Sequence), + Timestamp: audioTS, + }, + Payload: pkt.Payload, + } + audioTS += uint32(len(pkt.Payload)) + case miss.CodecOPUS: + name = core.CodecOpus + pkt2 = &core.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: uint16(pkt.Sequence), + Timestamp: audioTS, + }, + Payload: pkt.Payload, + } + // known cameras sends packets with 40ms long + audioTS += timestamp40ms + } + + for _, recv := range p.Receivers { + if recv.Codec.Name == name { + recv.WriteRTP(pkt2) + break + } + } + } +} + +// TimeToRTP convert time in milliseconds to RTP time +func TimeToRTP(timeMS, clockRate uint64) uint32 { + return uint32(timeMS * clockRate / 1000) +} diff --git a/www/add.html b/www/add.html index 98661fd3..38c4e155 100644 --- a/www/add.html +++ b/www/add.html @@ -413,6 +413,88 @@ + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + +