diff --git a/README.md b/README.md index d2d47ef8..7a257ae0 100644 --- a/README.md +++ b/README.md @@ -568,58 +568,38 @@ Tested: KD110, KC200, KC401, KC420WS, EC71. #### Source: Tuya -[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Open API`. +[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Tuya API`. -The `Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Open API` does not require a cloud project and the cameras can be added through the interface via QR code (user code required), but it does not support webrtc mode and two way audio. +The `Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Tuya API` does not require a cloud project and the cameras can be added through the interface via email/password. **Cloud API**: -- Obtain `device_id`, `client_id`, `client_secret`, and `uid` (if using `mode=webrtc`) from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). +- Obtain `device_id`, `client_id`, `client_secret`, and `uid` from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/). **Open API**: -- To get your user code, open the Tuya Smart app or Smart Life app and go to `Profile` > `Settings` > `Account and Security` > `User Code` -- Open the Go2rtc interface and go to `Add` > `Tuya` and enter your `User Code` in the `User Code` field. Click on `Generate QR Code` and scan it with the Tuya Smart app or Smart Life app. After scanning, click on `Login`. All cameras in your home (not shared ones) will be listed in the Go2rtc interface. Copy/Paste stream URLs to your `go2rtc.yaml` file. +- Smart Life accounts are not supported, you need to create a Tuya account. If the cameras are already added to the Smart Life app, you need to remove them and add them again to the Tuya Smart app. **Configuring the stream:** -- Use `mode` parameter to select the stream type (not all cameras support all modes): - - `webrtc` - WebRTC stream _(default for `Cloud API`)_ - - `rtsp` - RTSP stream _(default for `Open API`)_ - - `hls` - HLS stream - - `flv` - FLV stream _(only available for `Open API`)_ - - `rtmp` - RTMP stream _(only available for `Open API`)_ - -- Use `resolution` parameter to select the stream (only available for `Cloud API` and not all cameras support `hd` stream through WebRTC even if the camera has it): +- Use `resolution` parameter to select the stream (not all cameras support `hd` stream through WebRTC even if the camera has it): - `hd` - HD stream (default) - `sd` - SD stream ```yaml streams: - # Cloud API: WebRTC stream + # Cloud API: WebRTC main stream tuya_webrtc: - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX - - # Cloud API: WebRTC stream (same as above) - tuya_webrtc_2: - - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&mode=webrtc - # Cloud API: WebRTC stream (HD) - tuya_webrtc_hd: - - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=hd - - # Cloud API: WebRTC stream (SD) + # Cloud API: WebRTC sub stream tuya_webrtc_sd: - tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd - - # Cloud API: RTSP stream when available (no "uid" required) - tuya_rtsp: - - tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=rtsp - - # Cloud API: HLS stream when available (no "uid" required) - tuya_hls: - - tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=hls - # Open API: RTSP stream - tuya_openapi: - - tuya://apigw.tuyaeu.com?device_id=XXX&terminal_id=XXX&token=XXX&uid=XXX + # Tuya API: WebRTC main stream + tuya: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX + + # Tuya API: WebRTC sub stream + tuya: + - tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd ``` #### Source: GoPro diff --git a/internal/tuya/tuya.go b/internal/tuya/tuya.go index b5457253..cb25daa5 100644 --- a/internal/tuya/tuya.go +++ b/internal/tuya/tuya.go @@ -1,13 +1,13 @@ package tuya import ( + "bytes" "encoding/json" + "errors" "fmt" - "io" "net/http" "net/url" - "strings" - "time" + "strconv" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" @@ -15,8 +15,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/tuya" ) -var users = make(map[string]tuya.LoginResponse) - func Init() { streams.HandleFunc("tuya", func(source string) (core.Producer, error) { return tuya.Dial(source) @@ -27,74 +25,41 @@ func Init() { func apiTuya(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - userCode := query.Get("user_code") - token := query.Get("token") + region := query.Get("region") + email := query.Get("email") + password := query.Get("password") - if userCode == "" { - http.Error(w, "user_code is required", http.StatusBadRequest) + if email == "" || password == "" || region == "" { + http.Error(w, "email, password and region are required", http.StatusBadRequest) return } - var auth *tuya.LoginResponse - if loginResponse, ok := users[userCode]; ok { - expireTime := loginResponse.Timestamp + loginResponse.Result.ExpireTime - - if expireTime > time.Now().Unix() { - auth = &loginResponse - } else { - delete(users, userCode) - token = "" + var tuyaRegion *tuya.Region + for _, r := range tuya.AvailableRegions { + if r.Host == region { + tuyaRegion = &r + break } } - if auth == nil && token == "" { - qrCode, err := getQRCode(userCode) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // response qrCode - json.NewEncoder(w).Encode(map[string]interface{}{ - "qrCode": qrCode, - }) - + if tuyaRegion == nil { + http.Error(w, fmt.Sprintf("invalid region: %s", region), http.StatusBadRequest) return } - if auth == nil && token != "" { - authResponse, err := login(userCode, token) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + httpClient := tuya.CreateHTTPClientWithSession() - auth = authResponse - } - - if auth == nil { - http.Error(w, "failed to get auth", http.StatusInternalServerError) - return - } - - tokenInfo := tuya.TokenInfo{ - AccessToken: auth.Result.AccessToken, - ExpireTime: auth.Timestamp + auth.Result.ExpireTime, - RefreshToken: auth.Result.RefreshToken, - } - - tokenInfoBase64, err := tuya.ToBase64(&tokenInfo) + _, err := login(httpClient, tuyaRegion.Host, email, password, tuyaRegion.Continent) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("login failed: %v", err), http.StatusInternalServerError) return } - tuyaAPI, err := tuya.NewTuyaOpenApiClient( - strings.Replace(auth.Result.Endpoint, "https://", "", 1), - auth.Result.UID, - "", - auth.Result.TerminalID, - tokenInfo, + tuyaAPI, err := tuya.NewTuyaApiClient( + httpClient, + tuyaRegion.Host, + email, + password, "", ) @@ -103,25 +68,52 @@ func apiTuya(w http.ResponseWriter, r *http.Request) { return } - devices, err := tuyaAPI.GetAllDevices() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + var devices []tuya.Device + + homes, _ := tuyaAPI.GetHomeList() + if homes != nil && len(homes.Result) > 0 { + for _, home := range homes.Result { + roomList, err := tuyaAPI.GetRoomList(strconv.Itoa(home.Gid)) + if err != nil { + continue + } + + for _, room := range roomList.Result { + for _, device := range room.DeviceList { + if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) { + devices = append(devices, device) + } + } + } + } + } + + sharedHomes, _ := tuyaAPI.GetSharedHomeList() + if sharedHomes != nil && len(sharedHomes.Result.SecurityWebCShareInfoList) > 0 { + for _, sharedHome := range sharedHomes.Result.SecurityWebCShareInfoList { + for _, device := range sharedHome.DeviceInfoList { + if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) { + devices = append(devices, device) + } + } + } + } + + if len(devices) == 0 { + http.Error(w, "no cameras found", http.StatusNotFound) return } var items []*api.Source for _, device := range devices { cleanQuery := url.Values{} - cleanQuery.Set("uid", auth.Result.UID) - cleanQuery.Set("token", tokenInfoBase64) - cleanQuery.Set("terminal_id", auth.Result.TerminalID) - cleanQuery.Set("device_id", device.ID) - - endpoint := strings.Replace(auth.Result.Endpoint, "https://", "tuya://", 1) - url := fmt.Sprintf("%s?%s", endpoint, cleanQuery.Encode()) + cleanQuery.Set("device_id", device.DeviceId) + cleanQuery.Set("email", email) + cleanQuery.Set("password", password) + url := fmt.Sprintf("tuya://%s?%s", tuyaRegion.Host, cleanQuery.Encode()) items = append(items, &api.Source{ - Name: device.Name, + Name: device.DeviceName, URL: url, }) } @@ -129,86 +121,128 @@ func apiTuya(w http.ResponseWriter, r *http.Request) { api.ResponseSources(w, items) } -func login(userCode string, qrCode string) (*tuya.LoginResponse, error) { - url := fmt.Sprintf("https://%s/v1.0/m/life/home-assistant/qrcode/tokens/%s?clientid=%s&usercode=%s", tuya.TUYA_HOST, qrCode, tuya.TUYA_CLIENT_ID, userCode) - - req, err := http.NewRequest("GET", url, nil) +func login(client *http.Client, serverHost, email, password, countryCode string) (*tuya.LoginResult, error) { + tokenResp, err := getLoginToken(client, serverHost, email, countryCode) if err != nil { return nil, err } - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - - response, err := httpClient.Do(req) + encryptedPassword, err := tuya.EncryptPassword(password, tokenResp.Result.PbKey) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to encrypt password: %v", err) } - defer response.Body.Close() - res, err := io.ReadAll(response.Body) + var loginResp *tuya.PasswordLoginResponse + var url string + + loginReq := tuya.PasswordLoginRequest{ + CountryCode: countryCode, + Passwd: encryptedPassword, + Token: tokenResp.Result.Token, + IfEncrypt: 1, + Options: `{"group":1}`, + } + + if tuya.IsEmailAddress(email) { + url = fmt.Sprintf("https://%s/api/private/email/login", serverHost) + loginReq.Email = email + } else { + url = fmt.Sprintf("https://%s/api/private/phone/login", serverHost) + loginReq.Mobile = email + } + + loginResp, err = performLogin(client, url, loginReq, serverHost) + if err != nil { return nil, err } - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get QR code: %s", string(res)) + if !loginResp.Success { + return nil, errors.New(loginResp.ErrorMsg) } - var loginResponse tuya.LoginResponse - err = json.Unmarshal(res, &loginResponse) - if err != nil { - return nil, err - } - - if !loginResponse.Success { - return nil, fmt.Errorf("failed to login: %s", loginResponse.Msg) - } - - users[userCode] = loginResponse - - return &loginResponse, nil + return &loginResp.Result, nil } -func getQRCode(userCode string) (string, error) { - url := fmt.Sprintf("https://%s/v1.0/m/life/home-assistant/qrcode/tokens?clientid=%s&schema=%s&usercode=%s", tuya.TUYA_HOST, tuya.TUYA_CLIENT_ID, tuya.TUYA_SCHEMA, userCode) +func getLoginToken(client *http.Client, serverHost, username, countryCode string) (*tuya.LoginTokenResponse, error) { + url := fmt.Sprintf("https://%s/api/login/token", serverHost) - req, err := http.NewRequest("POST", url, nil) + tokenReq := tuya.LoginTokenRequest{ + CountryCode: countryCode, + Username: username, + IsUid: false, + } + + jsonData, err := json.Marshal(tokenReq) if err != nil { - return "", err + return nil, err } - req.Header.Set("Content-Type", "text/plain") - - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - - response, err := httpClient.Do(req) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { - return "", err + return nil, err } - defer response.Body.Close() - res, err := io.ReadAll(response.Body) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) if err != nil { - return "", err + return nil, err + } + defer resp.Body.Close() + + var tokenResp tuya.LoginTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, err } - if response.StatusCode != http.StatusOK { - return "", err + if !tokenResp.Success { + return nil, err } - var qrResponse tuya.QRResponse - err = json.Unmarshal(res, &qrResponse) - if err != nil { - return "", err - } - - if !qrResponse.Success { - return "", fmt.Errorf("failed to get QR code: %s", qrResponse.Msg) - } - - return qrResponse.Result.Code, nil + return &tokenResp, nil +} + +func performLogin(client *http.Client, url string, loginReq tuya.PasswordLoginRequest, serverHost string) (*tuya.PasswordLoginResponse, error) { + jsonData, err := json.Marshal(loginReq) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost)) + req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost)) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var loginResp tuya.PasswordLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return nil, err + } + + return &loginResp, nil +} + +func containsDevice(devices []tuya.Device, deviceID string) bool { + for _, device := range devices { + if device.DeviceId == deviceID { + return true + } + } + return false } diff --git a/pkg/tuya/client.go b/pkg/tuya/client.go index 7692639c..f5b964e9 100644 --- a/pkg/tuya/client.go +++ b/pkg/tuya/client.go @@ -7,7 +7,6 @@ import ( "net/url" "regexp" - "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/pion/rtp" @@ -50,38 +49,29 @@ func Dial(rawURL string) (core.Producer, error) { query := u.Query() - // Open API - tokenInfo := query.Get("token") - terminalId := query.Get("terminal_id") + // Tuya API + email := query.Get("email") + password := query.Get("password") // Cloud API + uid := query.Get("uid") clientId := query.Get("client_id") clientSecret := query.Get("client_secret") // Shared params deviceId := query.Get("device_id") - uid := query.Get("uid") // Stream params streamResolution := query.Get("resolution") - streamMode := query.Get("mode") - useOpenApi := deviceId != "" && uid != "" && tokenInfo != "" && terminalId != "" - useCloudApi := deviceId != "" && ((streamMode == "webrtc" || streamMode == "") && uid != "") && clientId != "" && clientSecret != "" + useTuyaApi := deviceId != "" && email != "" && password != "" + useCloudApi := deviceId != "" && uid != "" && clientId != "" && clientSecret != "" if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") { streamResolution = "hd" } - if streamMode == "" || (streamMode != "rtsp" && streamMode != "hls" && streamMode != "flv" && streamMode != "rtmp" && streamMode != "webrtc") { - if useOpenApi { - streamMode = "rtsp" - } else { - streamMode = "webrtc" - } - } - - if !useOpenApi && !useCloudApi { + if !useTuyaApi && !useCloudApi { return nil, errors.New("tuya: wrong query params") } @@ -89,25 +79,16 @@ func Dial(rawURL string) (core.Producer, error) { handlers: make(map[uint32]func(*rtp.Packet)), } - if useOpenApi { - if client.api, err = NewTuyaOpenApiClient(u.Hostname(), uid, deviceId, terminalId, tokenInfo, streamMode); err != nil { + if useTuyaApi { + if client.api, err = NewTuyaApiClient(nil, u.Hostname(), email, password, deviceId); err != nil { return nil, fmt.Errorf("tuya: %w", err) } } else { - if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret, streamMode); err != nil { + if client.api, err = NewTuyaCloudApiClient(u.Hostname(), uid, deviceId, clientId, clientSecret); err != nil { return nil, fmt.Errorf("tuya: %w", err) } } - if streamMode != "webrtc" { - streamUrl, err := client.api.GetStreamUrl(streamMode) - if err != nil { - return nil, fmt.Errorf("tuya: %w", err) - } - - return streams.GetProducer(streamUrl) - } - if err := client.api.Init(); err != nil { return nil, fmt.Errorf("tuya: %w", err) } diff --git a/pkg/tuya/cloud_api.go b/pkg/tuya/cloud_api.go index bd74daf0..4d25c2be 100644 --- a/pkg/tuya/cloud_api.go +++ b/pkg/tuya/cloud_api.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/md5" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -68,24 +69,26 @@ type OpenIoTHubConfigResponse struct { type TuyaCloudApiClient struct { TuyaClient + uid string clientId string clientSecret string + accessToken string + refreshToken string refreshingToken bool } -func NewTuyaCloudApiClient(baseUrl string, uid string, deviceId string, clientId string, clientSecret string, streamMode string) (*TuyaCloudApiClient, error) { +func NewTuyaCloudApiClient(baseUrl, uid, deviceId, clientId, clientSecret string) (*TuyaCloudApiClient, error) { mqttClient := NewTuyaMqttClient(deviceId) client := &TuyaCloudApiClient{ TuyaClient: TuyaClient{ httpClient: &http.Client{Timeout: 15 * time.Second}, mqtt: mqttClient, - uid: uid, deviceId: deviceId, - streamMode: streamMode, expireTime: 0, baseUrl: baseUrl, }, + uid: uid, clientId: clientId, clientSecret: clientSecret, refreshingToken: false, @@ -140,7 +143,7 @@ func (c *TuyaCloudApiClient) GetStreamUrl(streamType string) (streamUrl string, } if !allocResponse.Success { - return "", fmt.Errorf(allocResponse.Msg) + return "", errors.New(allocResponse.Msg) } return allocResponse.Result.URL, nil @@ -175,7 +178,7 @@ func (c *TuyaCloudApiClient) initToken() (err error) { } if !tokenResponse.Success { - return fmt.Errorf(tokenResponse.Msg) + return errors.New(tokenResponse.Msg) } c.accessToken = tokenResponse.Result.AccessToken diff --git a/pkg/tuya/crypto.go b/pkg/tuya/crypto.go deleted file mode 100644 index b8f84615..00000000 --- a/pkg/tuya/crypto.go +++ /dev/null @@ -1,134 +0,0 @@ -package tuya - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "math/rand" -) - -// https://github.com/tuya/tuya-device-sharing-sdk/blob/main/tuya_sharing/customerapi.py -func AesGCMEncrypt(rawData string, secret string) (string, error) { - nonce := []byte(RandomNonce(12)) - - block, err := aes.NewCipher([]byte(secret)) - if err != nil { - return "", err - } - - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - ciphertext := aesgcm.Seal(nil, nonce, []byte(rawData), nil) - nonceB64 := base64.StdEncoding.EncodeToString(nonce) - ciphertextB64 := base64.StdEncoding.EncodeToString(ciphertext) - - return nonceB64 + ciphertextB64, nil -} - -func AesGCMDecrypt(cipherData string, secret string) (string, error) { - if len(cipherData) <= 16 { - return "", fmt.Errorf("invalid ciphertext length") - } - - nonceB64 := cipherData[:16] - ciphertextB64 := cipherData[16:] - - nonce, err := base64.StdEncoding.DecodeString(nonceB64) - if err != nil { - return "", err - } - - ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64) - if err != nil { - return "", err - } - - block, err := aes.NewCipher([]byte(secret)) - if err != nil { - return "", err - } - - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", err - } - - return string(plaintext), nil -} - -func SecretGenerating(rid, sid, hashKey string) string { - message := hashKey - mod := 16 - - if sid != "" { - sidLength := len(sid) - length := sidLength - if length > mod { - length = mod - } - - ecode := "" - for i := 0; i < length; i++ { - idx := int(sid[i]) % mod - ecode += string(sid[idx]) - } - message += "_" - message += ecode - } - - h := hmac.New(sha256.New, []byte(rid)) - h.Write([]byte(message)) - byteTemp := h.Sum(nil) - secret := hex.EncodeToString(byteTemp) - - return secret[:16] -} - -func RestfulSign(hashKey, queryEncdata, bodyEncdata string, data map[string]string) string { - headers := []string{"X-appKey", "X-requestId", "X-sid", "X-time", "X-token"} - headerSignStr := "" - - for _, item := range headers { - val, exists := data[item] - if exists && val != "" { - headerSignStr += item + "=" + val + "||" - } - } - - signStr := "" - if len(headerSignStr) > 2 { - signStr = headerSignStr[:len(headerSignStr)-2] - } - - if queryEncdata != "" { - signStr += queryEncdata - } - if bodyEncdata != "" { - signStr += bodyEncdata - } - - h := hmac.New(sha256.New, []byte(hashKey)) - h.Write([]byte(signStr)) - return hex.EncodeToString(h.Sum(nil)) -} - -func RandomNonce(length int) string { - const charset = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678" - result := make([]byte, length) - for i := range result { - result[i] = charset[rand.Intn(len(charset))] - } - return string(result) -} diff --git a/pkg/tuya/helper.go b/pkg/tuya/helper.go index 0b97b256..7c9eb410 100644 --- a/pkg/tuya/helper.go +++ b/pkg/tuya/helper.go @@ -1,72 +1,69 @@ package tuya import ( - "encoding/base64" - "encoding/json" - "fmt" + "crypto/md5" + cryptoRand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "net/http" + "net/http/cookiejar" + "regexp" + "time" + + "golang.org/x/net/publicsuffix" ) -func FormToJSON(content any) string { - if content == nil { - return "{}" +func EncryptPassword(password, pbKey string) (string, error) { + // Hash password with MD5 + hasher := md5.New() + hasher.Write([]byte(password)) + hashedPassword := hex.EncodeToString(hasher.Sum(nil)) + + // Decode PEM public key + block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\n" + pbKey + "\n-----END PUBLIC KEY-----")) + if block == nil { + return "", errors.New("failed to decode PEM block") } - jsonBytes, err := json.Marshal(content) + pubKey, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { - return "{}" + return "", err } - return string(jsonBytes) + rsaPubKey, ok := pubKey.(*rsa.PublicKey) + if !ok { + return "", errors.New("not an RSA public key") + } + + // Encrypt with RSA + encrypted, err := rsa.EncryptPKCS1v15(cryptoRand.Reader, rsaPubKey, []byte(hashedPassword)) + if err != nil { + return "", err + } + + // Convert to hex string + return hex.EncodeToString(encrypted), nil } -func ToBase64(tokenInfo *TokenInfo) (string, error) { - jsonData, err := json.Marshal(tokenInfo) - if err != nil { - return "", fmt.Errorf("error marshalling token: %v", err) - } - - encoded := base64.URLEncoding.EncodeToString(jsonData) - - return encoded, nil +func IsEmailAddress(input string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(input) } -func FromBase64(encodedTokenInfo string) (*TokenInfo, error) { - jsonData, err := base64.URLEncoding.DecodeString(encodedTokenInfo) +func CreateHTTPClientWithSession() *http.Client { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { - return nil, fmt.Errorf("error decoding token: %v", err) + return nil } - var tokenInfo TokenInfo - err = json.Unmarshal(jsonData, &tokenInfo) - if err != nil { - return nil, fmt.Errorf("error unmarshalling token: %v", err) + return &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, } - - return &tokenInfo, nil -} - -func ParseTokenInfo(tokenInfoOrString any) (*TokenInfo, error) { - var tokenInfo *TokenInfo - var err error - - switch v := tokenInfoOrString.(type) { - case string: - tokenInfo, err = FromBase64(v) - if err != nil { - return nil, fmt.Errorf("failed to decode base64 token: %w", err) - } - case *TokenInfo: - tokenInfo = v - case TokenInfo: - copyOfV := v - tokenInfo = ©OfV - default: - return nil, fmt.Errorf("invalid type: %T", v) - } - - if tokenInfo == nil { - return nil, fmt.Errorf("token info is nil") - } - - return tokenInfo, nil } diff --git a/pkg/tuya/interface.go b/pkg/tuya/interface.go index dae19a82..f4f530aa 100644 --- a/pkg/tuya/interface.go +++ b/pkg/tuya/interface.go @@ -26,17 +26,13 @@ type TuyaAPI interface { type TuyaClient struct { TuyaAPI - httpClient *http.Client - mqtt *TuyaMqttClient - streamMode string - baseUrl string - accessToken string - refreshToken string - expireTime int64 - deviceId string - uid string - skill *Skill - iceServers []pionWebrtc.ICEServer + httpClient *http.Client + mqtt *TuyaMqttClient + baseUrl string + expireTime int64 + deviceId string + skill *Skill + iceServers []pionWebrtc.ICEServer } type AudioAttributes struct { @@ -44,11 +40,11 @@ type AudioAttributes struct { HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker } -type OpenApiICE struct { +type ICEServer struct { Urls string `json:"urls"` - Username string `json:"username"` - Credential string `json:"credential"` - TTL int `json:"ttl"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` + TTL int `json:"ttl,omitempty"` } type WebICE struct { @@ -58,7 +54,7 @@ type WebICE struct { } type P2PConfig struct { - Ices []OpenApiICE `json:"ices"` + Ices []ICEServer `json:"ices"` } type AudioSkill struct { diff --git a/pkg/tuya/open_api.go b/pkg/tuya/open_api.go deleted file mode 100644 index 4bd2a044..00000000 --- a/pkg/tuya/open_api.go +++ /dev/null @@ -1,473 +0,0 @@ -package tuya - -import ( - "crypto/md5" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/google/uuid" -) - -const ( - TUYA_HOST = "apigw.iotbing.com" - TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke" - TUYA_SCHEMA = "haauthorize" -) - -type OpenApiMQTTConfig struct { - ClientID string `json:"clientId"` - ExpireTime int `json:"expireTime"` - Password string `json:"password"` - Topic struct { - DevID struct { - Pub string `json:"pub"` - Sub string `json:"sub"` - } `json:"devId"` - OwnerID struct { - Sub string `json:"sub"` - } `json:"ownerId"` - } `json:"topic"` - URL string `json:"url"` - Username string `json:"username"` -} - -type OpenApiMQTTConfigRequest struct { - LinkID string `json:"linkId"` -} - -type OpenApiMQTTConfigResponse struct { - Success bool `json:"success"` - Result OpenApiMQTTConfig `json:"result"` - Msg string `json:"msg,omitempty"` -} - -type TokenInfo struct { - AccessToken string `json:"access_token"` - ExpireTime int64 `json:"expire_time"` - RefreshToken string `json:"refresh_token"` -} - -type LoginResult struct { - AccessToken string `json:"access_token"` - Endpoint string `json:"endpoint"` - ExpireTime int64 `json:"expire_time"` // seconds - RefreshToken string `json:"refresh_token"` - TerminalID string `json:"terminal_id"` - UID string `json:"uid"` - Username string `json:"username"` -} - -type LoginResponse struct { - Timestamp int64 `json:"t"` - Success bool `json:"success"` - Result LoginResult `json:"result"` - Msg string `json:"msg,omitempty"` -} - -type QRResponse struct { - Success bool `json:"success"` - Result struct { - Code string `json:"qrcode"` - } `json:"result"` - Msg string `json:"msg,omitempty"` -} - -type Home struct { - ID int `json:"id"` - Name string `json:"name"` - OwnerID string `json:"ownerId"` - Background string `json:"background"` - GeoName string `json:"geoName"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - GmtCreate int64 `json:"gmtCreate"` - GmtModified int64 `json:"gmtModified"` - GroupID int64 `json:"groupId"` - Status bool `json:"status"` - UID string `json:"uid"` -} - -type HomesResponse struct { - Success bool `json:"success"` - Result []Home `json:"result"` - Msg string `json:"msg,omitempty"` -} - -type DeviceFunction struct { - Code string `json:"code"` - Desc string `json:"desc"` - Name string `json:"name"` - Type string `json:"type"` - Values map[string]any `json:"values"` -} - -type DeviceStatusRange struct { - Code string `json:"code"` - Type string `json:"type"` - Values map[string]any `json:"values"` -} - -type Device struct { - ID string `json:"id"` - Name string `json:"name"` - LocalKey string `json:"local_key"` - Category string `json:"category"` - ProductID string `json:"product_id"` - ProductName string `json:"product_name"` - Sub bool `json:"sub"` - UUID string `json:"uuid"` - AssetID string `json:"asset_id"` - Online bool `json:"online"` - Icon string `json:"icon"` - IP string `json:"ip"` - TimeZone string `json:"time_zone"` - ActiveTime int64 `json:"active_time"` - CreateTime int64 `json:"create_time"` - UpdateTime int64 `json:"update_time"` -} - -type DeviceRequest struct { - HomeID string `json:"homeId"` -} - -type DeviceResponse struct { - Success bool `json:"success"` - Result []Device `json:"result"` - Msg string `json:"msg,omitempty"` -} - -type TuyaOpenApiClient struct { - TuyaClient - terminalId string - refreshingToken bool -} - -func NewTuyaOpenApiClient( - baseUrl string, - uid string, - deviceId string, - terminalId string, - tokenInfoOrString any, - streamMode string, -) (*TuyaOpenApiClient, error) { - tokenInfo, err := ParseTokenInfo(tokenInfoOrString) - if err != nil { - return nil, fmt.Errorf("failed to parse token info: %w", err) - } - - mqttClient := NewTuyaMqttClient(deviceId) - - client := &TuyaOpenApiClient{ - TuyaClient: TuyaClient{ - httpClient: &http.Client{Timeout: 15 * time.Second}, - mqtt: mqttClient, - uid: uid, - deviceId: deviceId, - accessToken: tokenInfo.AccessToken, - refreshToken: tokenInfo.RefreshToken, - expireTime: tokenInfo.ExpireTime, - streamMode: streamMode, - baseUrl: baseUrl, - }, - terminalId: terminalId, - refreshingToken: false, - } - - return client, nil -} - -// WebRTC Flow (not supported yet) -func (c *TuyaOpenApiClient) Init() error { - if err := c.initToken(); err != nil { - return fmt.Errorf("failed to initialize token: %w", err) - } - - return fmt.Errorf("stream mode %s is not supported", c.streamMode) -} - -func (c *TuyaOpenApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { - if err := c.initToken(); err != nil { - return "", fmt.Errorf("failed to initialize token: %w", err) - } - - urlPath := fmt.Sprintf("/v1.0/m/ipc/%s/stream/actions/allocate", c.deviceId) - - request := &AllocateRequest{ - Type: streamType, - } - - body, err := c.request("POST", urlPath, nil, request) - if err != nil { - return "", err - } - - var allocResponse AllocateResponse - err = json.Unmarshal(body, &allocResponse) - if err != nil { - return "", err - } - - if !allocResponse.Success { - return "", fmt.Errorf(allocResponse.Msg) - } - - return allocResponse.Result.URL, nil -} - -func (c *TuyaOpenApiClient) GetAllDevices() ([]Device, error) { - homes, err := c.queryHomes() - if err != nil { - return nil, err - } - - time.Sleep(2 * time.Second) - deviceMap := make(map[string]Device) - - for i, home := range homes { - if i > 0 { - time.Sleep(500 * time.Millisecond) - } - - devices, err := c.queryDevicesByHome(home.OwnerID) - if err != nil { - return nil, err - } - - for _, device := range devices { - // https://github.com/home-assistant/core/blob/088cfc3576e0018ad1df373c08549092918e6530/homeassistant/components/tuya/camera.py#L19 - if device.Category == "sp" || device.Category == "dghsxj" { - deviceMap[device.ID] = device - } - } - } - - var devices []Device - for _, device := range deviceMap { - devices = append(devices, device) - } - - return devices, nil -} - -func (c *TuyaOpenApiClient) loadHubConfig() (config *MQTTConfig, err error) { - request := OpenApiMQTTConfigRequest{ - LinkID: fmt.Sprintf("tuya-device-sharing-sdk-go.%s", uuid.New().String()), - } - - body, err := c.request("POST", "/v1.0/m/life/ha/access/config", nil, request) - if err != nil { - return nil, err - } - - var mqttConfigResponse OpenApiMQTTConfigResponse - if err := json.Unmarshal(body, &mqttConfigResponse); err != nil { - return nil, err - } - - if !mqttConfigResponse.Success { - return nil, fmt.Errorf("failed to get MQTT config: %s", mqttConfigResponse.Msg) - } - - return &MQTTConfig{ - Url: mqttConfigResponse.Result.URL, - Username: mqttConfigResponse.Result.Username, - Password: mqttConfigResponse.Result.Password, - ClientID: mqttConfigResponse.Result.ClientID, - PublishTopic: mqttConfigResponse.Result.Topic.DevID.Pub, - SubscribeTopic: mqttConfigResponse.Result.Topic.DevID.Sub, - }, nil -} - -func (c *TuyaOpenApiClient) queryHomes() ([]Home, error) { - body, err := c.request("GET", "/v1.0/m/life/users/homes", nil, nil) - if err != nil { - return nil, err - } - - var homesResponse HomesResponse - if err := json.Unmarshal(body, &homesResponse); err != nil { - return nil, err - } - - if !homesResponse.Success { - return nil, fmt.Errorf("failed to get homes: %s", homesResponse.Msg) - } - - return homesResponse.Result, nil -} - -func (c *TuyaOpenApiClient) queryDevicesByHome(homeID string) ([]Device, error) { - params := DeviceRequest{ - HomeID: homeID, - } - - body, err := c.request("GET", "/v1.0/m/life/ha/home/devices", params, nil) - if err != nil { - return nil, err - } - - var devicesResponse DeviceResponse - if err := json.Unmarshal(body, &devicesResponse); err != nil { - return nil, err - } - - if !devicesResponse.Success { - return nil, fmt.Errorf("failed to get devices: %s", devicesResponse.Msg) - } - - return devicesResponse.Result, nil -} - -// https://github.com/tuya/tuya-device-sharing-sdk/blob/main/tuya_sharing/customerapi.py -func (c *TuyaOpenApiClient) request( - method string, - path string, - params any, - body any, -) ([]byte, error) { - rid := uuid.New().String() - sid := "" - - md5Hash := md5.New() - ridRefreshToken := rid + c.refreshToken - md5Hash.Write([]byte(ridRefreshToken)) - hashKey := hex.EncodeToString(md5Hash.Sum(nil)) - secret := SecretGenerating(rid, sid, hashKey) - - queryEncdata := "" - var reqURL string - if params != nil { - jsonData := FormToJSON(params) - - encryptedData, err := AesGCMEncrypt(jsonData, secret) - if err != nil { - return nil, err - } - - queryEncdata = encryptedData - reqURL = fmt.Sprintf("https://%s%s?encdata=%s", c.baseUrl, path, queryEncdata) - } else { - reqURL = fmt.Sprintf("https://%s%s", c.baseUrl, path) - } - - bodyEncdata := "" - var reqBody io.Reader - if body != nil { - jsonData := FormToJSON(body) - - encryptedData, err := AesGCMEncrypt(jsonData, secret) - if err != nil { - return nil, err - } - - bodyEncdata = encryptedData - encBody := map[string]string{"encdata": bodyEncdata} - bodyBytes, _ := json.Marshal(encBody) - reqBody = strings.NewReader(string(bodyBytes)) - } - - req, err := http.NewRequest(method, reqURL, reqBody) - if err != nil { - return nil, err - } - - t := time.Now().Add(2*time.Second).UnixNano() / int64(time.Millisecond) - headers := map[string]string{ - "X-appKey": TUYA_CLIENT_ID, - "X-requestId": rid, - "X-sid": sid, - "X-time": fmt.Sprintf("%d", t), - "Content-Type": "application/json", - } - - if c.accessToken != "" { - headers["X-token"] = c.accessToken - } - - sign := RestfulSign(hashKey, queryEncdata, bodyEncdata, headers) - headers["X-sign"] = sign - - for key, value := range headers { - req.Header.Set(key, value) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var resultObj map[string]any - if err := json.Unmarshal(respBody, &resultObj); err != nil { - return nil, err - } - - if resultStr, ok := resultObj["result"].(string); ok { - decrypted, err := AesGCMDecrypt(resultStr, secret) - if err != nil { - return nil, err - } - - var decryptedObj any - if err := json.Unmarshal([]byte(decrypted), &decryptedObj); err == nil { - resultObj["result"] = decryptedObj - } else { - resultObj["result"] = decrypted - } - - updatedResponse, err := json.Marshal(resultObj) - if err != nil { - return nil, fmt.Errorf("failed to marshal updated response: %w", err) - } - - return updatedResponse, nil - } - - return respBody, nil -} - -func (c *TuyaOpenApiClient) initToken() error { - if c.refreshingToken { - return nil - } - - now := time.Now().Unix() - if (c.expireTime - 60) > now { - return nil - } - - c.refreshingToken = true - - urlPath := fmt.Sprintf("/v1.0/m/token/%s", c.refreshToken) - - body, err := c.request("GET", urlPath, nil, nil) - if err != nil { - return err - } - - var loginResponse LoginResponse - if err := json.Unmarshal(body, &loginResponse); err != nil { - return err - } - - if !loginResponse.Success { - return fmt.Errorf("failed to get token: %s", loginResponse.Msg) - } - - c.accessToken = loginResponse.Result.AccessToken - c.refreshToken = loginResponse.Result.RefreshToken - c.expireTime = loginResponse.Timestamp + loginResponse.Result.ExpireTime - c.refreshingToken = false - - return nil -} diff --git a/pkg/tuya/tuya_api.go b/pkg/tuya/tuya_api.go new file mode 100644 index 00000000..454efb46 --- /dev/null +++ b/pkg/tuya/tuya_api.go @@ -0,0 +1,590 @@ +package tuya + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "time" + + "github.com/AlexxIT/go2rtc/pkg/webrtc" +) + +type LoginTokenRequest struct { + CountryCode string `json:"countryCode"` + Username string `json:"username"` + IsUid bool `json:"isUid"` +} + +type LoginTokenResponse struct { + Result LoginToken `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type LoginToken struct { + Token string `json:"token"` + Exponent string `json:"exponent"` + PublicKey string `json:"publicKey"` + PbKey string `json:"pbKey"` +} + +type PasswordLoginRequest struct { + CountryCode string `json:"countryCode"` + Email string `json:"email,omitempty"` + Mobile string `json:"mobile,omitempty"` + Passwd string `json:"passwd"` + Token string `json:"token"` + IfEncrypt int `json:"ifencrypt"` + Options string `json:"options"` +} + +type PasswordLoginResponse struct { + Result LoginResult `json:"result"` + Success bool `json:"success"` + Status string `json:"status"` + ErrorMsg string `json:"errorMsg,omitempty"` +} + +type LoginResult struct { + Attribute int `json:"attribute"` + ClientId string `json:"clientId"` + DataVersion int `json:"dataVersion"` + Domain Domain `json:"domain"` + Ecode string `json:"ecode"` + Email string `json:"email"` + Extras Extras `json:"extras"` + HeadPic string `json:"headPic"` + ImproveCompanyInfo bool `json:"improveCompanyInfo"` + Nickname string `json:"nickname"` + PartnerIdentity string `json:"partnerIdentity"` + PhoneCode string `json:"phoneCode"` + Receiver string `json:"receiver"` + RegFrom int `json:"regFrom"` + Sid string `json:"sid"` + SnsNickname string `json:"snsNickname"` + TempUnit int `json:"tempUnit"` + Timezone string `json:"timezone"` + TimezoneId string `json:"timezoneId"` + Uid string `json:"uid"` + UserType int `json:"userType"` + Username string `json:"username"` +} + +type Domain struct { + AispeechHttpsUrl string `json:"aispeechHttpsUrl"` + AispeechQuicUrl string `json:"aispeechQuicUrl"` + DeviceHttpUrl string `json:"deviceHttpUrl"` + DeviceHttpsPskUrl string `json:"deviceHttpsPskUrl"` + DeviceHttpsUrl string `json:"deviceHttpsUrl"` + DeviceMediaMqttUrl string `json:"deviceMediaMqttUrl"` + DeviceMediaMqttsUrl string `json:"deviceMediaMqttsUrl"` + DeviceMqttsPskUrl string `json:"deviceMqttsPskUrl"` + DeviceMqttsUrl string `json:"deviceMqttsUrl"` + GwApiUrl string `json:"gwApiUrl"` + GwMqttUrl string `json:"gwMqttUrl"` + HttpPort int `json:"httpPort"` + HttpsPort int `json:"httpsPort"` + HttpsPskPort int `json:"httpsPskPort"` + MobileApiUrl string `json:"mobileApiUrl"` + MobileMediaMqttUrl string `json:"mobileMediaMqttUrl"` + MobileMqttUrl string `json:"mobileMqttUrl"` + MobileMqttsUrl string `json:"mobileMqttsUrl"` + MobileQuicUrl string `json:"mobileQuicUrl"` + MqttPort int `json:"mqttPort"` + MqttQuicUrl string `json:"mqttQuicUrl"` + MqttsPort int `json:"mqttsPort"` + MqttsPskPort int `json:"mqttsPskPort"` + RegionCode string `json:"regionCode"` +} + +type Extras struct { + HomeId string `json:"homeId"` + SceneType string `json:"sceneType"` +} + +type AppInfoResponse struct { + Result AppInfo `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type AppInfo struct { + AppId int `json:"appId"` + AppName string `json:"appName"` + ClientId string `json:"clientId"` + Icon string `json:"icon"` +} + +type MQTTConfigResponse struct { + Result TuyaApiMQTTConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type TuyaApiMQTTConfig struct { + Msid string `json:"msid"` + Password string `json:"password"` +} + +type HomeListResponse struct { + Result []Home `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SharedHomeListResponse struct { + Result SharedHome `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type SharedHome struct { + SecurityWebCShareInfoList []struct { + DeviceInfoList []Device `json:"deviceInfoList"` + Nickname string `json:"nickname"` + Username string `json:"username"` + } `json:"securityWebCShareInfoList"` +} + +type Home struct { + Admin bool `json:"admin"` + Background string `json:"background"` + DealStatus int `json:"dealStatus"` + DisplayOrder int `json:"displayOrder"` + GeoName string `json:"geoName"` + Gid int `json:"gid"` + GmtCreate int64 `json:"gmtCreate"` + GmtModified int64 `json:"gmtModified"` + GroupId int `json:"groupId"` + GroupUserId int `json:"groupUserId"` + Id int `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + ManagementStatus bool `json:"managementStatus"` + Name string `json:"name"` + OwnerId string `json:"ownerId"` + Role int `json:"role"` + Status bool `json:"status"` + Uid string `json:"uid"` +} + +type RoomListRequest struct { + HomeId string `json:"homeId"` +} + +type RoomListResponse struct { + Result []Room `json:"result"` + T int64 `json:"t"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type Room struct { + DeviceCount int `json:"deviceCount"` + DeviceList []Device `json:"deviceList"` + RoomId string `json:"roomId"` + RoomName string `json:"roomName"` +} + +type Device struct { + Category string `json:"category"` + DeviceId string `json:"deviceId"` + DeviceName string `json:"deviceName"` + P2pType int `json:"p2pType"` + ProductId string `json:"productId"` + SupportCloudStorage bool `json:"supportCloudStorage"` + Uuid string `json:"uuid"` +} + +type TuyaApiWebRTCConfigRequest struct { + DevId string `json:"devId"` + ClientTraceId string `json:"clientTraceId"` +} + +type TuyaApiWebRTCConfigResponse struct { + Result TuyaWebRTCConfig `json:"result"` + Success bool `json:"success"` + Msg string `json:"errorMsg,omitempty"` +} + +type TuyaWebRTCConfig struct { + AudioAttributes AudioAttributes `json:"audioAttributes"` + Auth string `json:"auth"` + GatewayId string `json:"gatewayId"` + Id string `json:"id"` + LocalKey string `json:"localKey"` + MotoId string `json:"motoId"` + NodeId string `json:"nodeId"` + P2PConfig P2PConfig `json:"p2pConfig"` + ProtocolVersion string `json:"protocolVersion"` + Skill string `json:"skill"` + Sub bool `json:"sub"` + SupportWebrtcRecord bool `json:"supportWebrtcRecord"` + SupportsPtz bool `json:"supportsPtz"` + SupportsWebrtc bool `json:"supportsWebrtc"` + VedioClarity int `json:"vedioClarity"` + VedioClaritys []int `json:"vedioClaritys"` + VideoClarity int `json:"videoClarity"` +} + +type TuyaApiClient struct { + TuyaClient + + email string + password string + countryCode string + mqttsUrl string +} + +type Region struct { + Name string `json:"name"` + Host string `json:"host"` + Description string `json:"description"` + Continent string `json:"continent"` +} + +var AvailableRegions = []Region{ + {"eu-central", "protect-eu.ismartlife.me", "Central Europe", "EU"}, + {"eu-east", "protect-we.ismartlife.me", "East Europe", "EU"}, + {"us-west", "protect-us.ismartlife.me", "West America", "AZ"}, + {"us-east", "protect-ue.ismartlife.me", "East America", "AZ"}, + {"china", "protect.ismartlife.me", "China", "AY"}, + {"india", "protect-in.ismartlife.me", "India", "IN"}, +} + +func NewTuyaApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaApiClient, error) { + var region *Region + for _, r := range AvailableRegions { + if r.Host == baseUrl { + region = &r + break + } + } + + if region == nil { + return nil, fmt.Errorf("invalid region: %s", baseUrl) + } + + if httpClient == nil { + httpClient = CreateHTTPClientWithSession() + } + + mqttClient := NewTuyaMqttClient(deviceId) + + client := &TuyaApiClient{ + TuyaClient: TuyaClient{ + httpClient: httpClient, + mqtt: mqttClient, + deviceId: deviceId, + expireTime: 0, + baseUrl: baseUrl, + }, + email: email, + password: password, + countryCode: region.Continent, + } + + return client, nil +} + +// WebRTC Flow +func (c *TuyaApiClient) Init() error { + if err := c.initToken(); err != nil { + return fmt.Errorf("failed to initialize token: %w", err) + } + + webrtcConfig, err := c.loadWebrtcConfig() + if err != nil { + return fmt.Errorf("failed to load webrtc config: %w", err) + } + + hubConfig, err := c.loadHubConfig() + if err != nil { + return fmt.Errorf("failed to load hub config: %w", err) + } + + if err := c.mqtt.Start(hubConfig, webrtcConfig, c.skill.WebRTC); err != nil { + return fmt.Errorf("failed to start MQTT: %w", err) + } + + return nil +} + +func (c *TuyaApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { + return "", errors.New("not supported") +} + +func (c *TuyaApiClient) GetAppInfo() (*AppInfoResponse, error) { + url := fmt.Sprintf("https://%s/api/customized/web/app/info", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var appInfoResponse AppInfoResponse + if err := json.Unmarshal(body, &appInfoResponse); err != nil { + return nil, err + } + + if !appInfoResponse.Success { + return nil, errors.New(appInfoResponse.Msg) + } + + return &appInfoResponse, nil +} + +func (c *TuyaApiClient) GetHomeList() (*HomeListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/common/homeList", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var homeListResponse HomeListResponse + if err := json.Unmarshal(body, &homeListResponse); err != nil { + return nil, err + } + + if !homeListResponse.Success { + return nil, errors.New(homeListResponse.Msg) + } + + return &homeListResponse, nil +} + +func (c *TuyaApiClient) GetSharedHomeList() (*SharedHomeListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/playback/shareList", c.baseUrl) + + body, err := c.request("POST", url, nil) + if err != nil { + return nil, err + } + + var sharedHomeListResponse SharedHomeListResponse + if err := json.Unmarshal(body, &sharedHomeListResponse); err != nil { + return nil, err + } + + if !sharedHomeListResponse.Success { + return nil, errors.New(sharedHomeListResponse.Msg) + } + + return &sharedHomeListResponse, nil +} + +func (c *TuyaApiClient) GetRoomList(homeId string) (*RoomListResponse, error) { + url := fmt.Sprintf("https://%s/api/new/common/roomList", c.baseUrl) + + data := RoomListRequest{ + HomeId: homeId, + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var roomListResponse RoomListResponse + if err := json.Unmarshal(body, &roomListResponse); err != nil { + return nil, err + } + + if !roomListResponse.Success { + return nil, errors.New(roomListResponse.Msg) + } + + return &roomListResponse, nil +} + +func (c *TuyaApiClient) initToken() error { + tokenUrl := fmt.Sprintf("https://%s/api/login/token", c.baseUrl) + + tokenReq := LoginTokenRequest{ + CountryCode: c.countryCode, + Username: c.email, + IsUid: false, + } + + body, err := c.request("POST", tokenUrl, tokenReq) + if err != nil { + return err + } + + var tokenResp LoginTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return err + } + + if !tokenResp.Success { + return errors.New(tokenResp.Msg) + } + + encryptedPassword, err := EncryptPassword(c.password, tokenResp.Result.PbKey) + if err != nil { + return fmt.Errorf("failed to encrypt password: %v", err) + } + var loginUrl string + + loginReq := PasswordLoginRequest{ + CountryCode: c.countryCode, + Passwd: encryptedPassword, + Token: tokenResp.Result.Token, + IfEncrypt: 1, + Options: `{"group":1}`, + } + + if IsEmailAddress(c.email) { + loginUrl = fmt.Sprintf("https://%s/api/private/email/login", c.baseUrl) + loginReq.Email = c.email + } else { + loginUrl = fmt.Sprintf("https://%s/api/private/phone/login", c.baseUrl) + loginReq.Mobile = c.email + } + + body, err = c.request("POST", loginUrl, loginReq) + if err != nil { + return err + } + + var loginResp *PasswordLoginResponse + if err := json.Unmarshal(body, &loginResp); err != nil { + return err + } + + if !loginResp.Success { + return errors.New(loginResp.ErrorMsg) + } + + c.mqttsUrl = fmt.Sprintf("wss://%s/mqtt", loginResp.Result.Domain.MobileMqttsUrl) + c.expireTime = time.Now().Unix() + 2*24*60*60 // 2 days in seconds + + return nil +} + +func (c *TuyaApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { + url := fmt.Sprintf("https://%s/api/jarvis/config", c.baseUrl) + + data := TuyaApiWebRTCConfigRequest{ + DevId: c.deviceId, + ClientTraceId: fmt.Sprintf("%x", rand.Int63()), + } + + body, err := c.request("POST", url, data) + if err != nil { + return nil, err + } + + var webRTCConfigResponse TuyaApiWebRTCConfigResponse + err = json.Unmarshal(body, &webRTCConfigResponse) + if err != nil { + return nil, err + } + + if !webRTCConfigResponse.Success { + return nil, errors.New(webRTCConfigResponse.Msg) + } + + err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &c.skill) + if err != nil { + return nil, err + } + + iceServers, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) + if err != nil { + return nil, err + } + + c.iceServers, err = webrtc.UnmarshalICEServers(iceServers) + if err != nil { + return nil, err + } + + return &WebRTCConfig{ + AudioAttributes: webRTCConfigResponse.Result.AudioAttributes, + Auth: webRTCConfigResponse.Result.Auth, + ID: webRTCConfigResponse.Result.Id, + MotoID: webRTCConfigResponse.Result.MotoId, + P2PConfig: webRTCConfigResponse.Result.P2PConfig, + ProtocolVersion: webRTCConfigResponse.Result.ProtocolVersion, + Skill: webRTCConfigResponse.Result.Skill, + SupportsWebRTCRecord: webRTCConfigResponse.Result.SupportWebrtcRecord, + SupportsWebRTC: webRTCConfigResponse.Result.SupportsWebrtc, + VedioClaritiy: webRTCConfigResponse.Result.VedioClarity, + VideoClaritiy: webRTCConfigResponse.Result.VideoClarity, + VideoClarities: webRTCConfigResponse.Result.VedioClaritys, + }, nil +} + +func (c *TuyaApiClient) loadHubConfig() (config *MQTTConfig, err error) { + mqttUrl := fmt.Sprintf("https://%s/api/jarvis/mqtt", c.baseUrl) + + mqttBody, err := c.request("POST", mqttUrl, nil) + if err != nil { + return nil, err + } + + var mqttConfigResponse MQTTConfigResponse + err = json.Unmarshal(mqttBody, &mqttConfigResponse) + if err != nil { + return nil, err + } + + if !mqttConfigResponse.Success { + return nil, errors.New(mqttConfigResponse.Msg) + } + + return &MQTTConfig{ + Url: c.mqttsUrl, + ClientID: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid), + Username: fmt.Sprintf("web_%s", mqttConfigResponse.Result.Msid), + Password: mqttConfigResponse.Result.Password, + PublishTopic: "/av/moto/moto_id/u/{device_id}", + SubscribeTopic: fmt.Sprintf("/av/u/%s", mqttConfigResponse.Result.Msid), + }, nil +} + +func (c *TuyaApiClient) request(method string, url string, body any) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", fmt.Sprintf("https://%s", c.baseUrl)) + + response, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + res, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} \ No newline at end of file diff --git a/www/add.html b/www/add.html index f16d4d45..5a04cd96 100644 --- a/www/add.html +++ b/www/add.html @@ -28,7 +28,6 @@ } -
@@ -283,14 +282,17 @@Attention: Cameras added through QR Code does not support webrtc mode!
- - - @@ -298,91 +300,31 @@