mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 23:57:20 +08:00
598 lines
16 KiB
Go
598 lines
16 KiB
Go
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("wss://%s/mqtt", loginResp.Result.Domain.MobileMqttsUrl)
|
|
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
|
|
}
|