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 SmartApiMQTTConfig `json:"result"` Success bool `json:"success"` Msg string `json:"errorMsg,omitempty"` } type SmartApiMQTTConfig 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 SmartApiWebRTCConfigRequest struct { DevId string `json:"devId"` ClientTraceId string `json:"clientTraceId"` } type SmartApiWebRTCConfigResponse struct { Result SmartApiWebRTCConfig `json:"result"` Success bool `json:"success"` Msg string `json:"errorMsg,omitempty"` } type SmartApiWebRTCConfig 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 TuyaSmartApiClient 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 NewTuyaSmartApiClient(httpClient *http.Client, baseUrl, email, password, deviceId string) (*TuyaSmartApiClient, 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 := &TuyaSmartApiClient{ 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 *TuyaSmartApiClient) 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) } if c.skill.LowPower > 0 { _ = c.mqtt.WakeUp(c.localKey) } return nil } func (c *TuyaSmartApiClient) GetStreamUrl(streamType string) (streamUrl string, err error) { return "", errors.New("not supported") } func (c *TuyaSmartApiClient) 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 *TuyaSmartApiClient) 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 *TuyaSmartApiClient) 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 *TuyaSmartApiClient) 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 *TuyaSmartApiClient) 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("ssl://%s:%d", loginResp.Result.Domain.MobileMqttsUrl, loginResp.Result.Domain.MqttsPort) c.expireTime = time.Now().Unix() + 2*24*60*60 // 2 days in seconds return nil } func (c *TuyaSmartApiClient) loadWebrtcConfig() (*WebRTCConfig, error) { url := fmt.Sprintf("https://%s/api/jarvis/config", c.baseUrl) data := SmartApiWebRTCConfigRequest{ DevId: c.deviceId, ClientTraceId: fmt.Sprintf("%x", rand.Int63()), } body, err := c.request("POST", url, data) if err != nil { return nil, err } var webRTCConfigResponse SmartApiWebRTCConfigResponse 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 } // Store LocalKey c.localKey = webRTCConfigResponse.Result.LocalKey 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 *TuyaSmartApiClient) 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 *TuyaSmartApiClient) 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 }