package tuya import ( "bytes" "crypto/md5" "encoding/json" "fmt" "io" "net/http" "strconv" "time" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/google/uuid" pionWebrtc "github.com/pion/webrtc/v4" ) type TuyaClient struct { httpClient *http.Client mqtt *TuyaMQTT apiURL string sessionID string clientID string deviceID string accessToken string refreshToken string secret string expireTime int64 uid string motoID string auth string iceServers []pionWebrtc.ICEServer medias []*core.Media } type Token struct { UID string `json:"uid"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpireTime int64 `json:"expire_time"` } type AudioAttributes struct { CallMode []int `json:"call_mode"` // 1 = one way, 2 = two way HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker } type OpenApiICE struct { Urls string `json:"urls"` Username string `json:"username"` Credential string `json:"credential"` TTL int `json:"ttl"` } type WebICE struct { Urls string `json:"urls"` Username string `json:"username,omitempty"` Credential string `json:"credential,omitempty"` } type P2PConfig struct { Ices []OpenApiICE `json:"ices"` } type Skill struct { WebRTC int `json:"webrtc"` Audios []struct { Channels int `json:"channels"` DataBit int `json:"dataBit"` CodecType int `json:"codecType"` SampleRate int `json:"sampleRate"` } `json:"audios"` Videos []struct { StreamType int `json:"streamType"` // streamType = 2 => H265 and streamType = 4 => H264 ProfileId string `json:"profileId"` Width int `json:"width"` CodecType int `json:"codecType"` SampleRate int `json:"sampleRate"` Height int `json:"height"` } `json:"videos"` } type WebRTConfig struct { AudioAttributes AudioAttributes `json:"audio_attributes"` Auth string `json:"auth"` ID string `json:"id"` MotoID string `json:"moto_id"` P2PConfig P2PConfig `json:"p2p_config"` Skill string `json:"skill"` SupportsWebRTC bool `json:"supports_webrtc"` VideoClaritiy int `json:"video_clarity"` } type WebRTCConfigResponse struct { Result WebRTConfig `json:"result"` } type TokenResponse struct { Result Token `json:"result"` } type OpenIoTHubConfigRequest struct { UID string `json:"uid"` UniqueID string `json:"unique_id"` LinkType string `json:"link_type"` Topics string `json:"topics"` } type OpenIoTHubConfigResponse struct { Success bool `json:"success"` Result OpenIoTHubConfig `json:"result"` } type OpenIoTHubConfig struct { Url string `json:"url"` ClientID string `json:"client_id"` Username string `json:"username"` Password string `json:"password"` SinkTopic struct { IPC string `json:"ipc"` } `json:"sink_topic"` SourceSink struct { IPC string `json:"ipc"` } `json:"source_topic"` ExpireTime int `json:"expire_time"` } const ( defaultTimeout = 5 * time.Second ) func NewTuyaClient(openAPIURL string, deviceID string, uid string, clientID string, secret string) (*TuyaClient, error) { client := &TuyaClient{ httpClient: &http.Client{Timeout: defaultTimeout}, mqtt: &TuyaMQTT{waiter: core.Waiter{}}, apiURL: openAPIURL, sessionID: core.RandString(6, 62), clientID: clientID, deviceID: deviceID, secret: secret, uid: uid, } if err := client.InitToken(); err != nil { return nil, fmt.Errorf("failed to initialize token: %w", err) } if err := client.InitDevice(); err != nil { return nil, fmt.Errorf("failed to initialize device: %w", err) } if err := client.StartMQTT(); err != nil { return nil, fmt.Errorf("failed to start MQTT: %w", err) } return client, nil } func (c *TuyaClient) Close() { c.StopMQTT() c.httpClient.CloseIdleConnections() } func(c *TuyaClient) 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, fmt.Errorf("failed to marshal request body: %w", err) } bodyReader = bytes.NewReader(jsonBody) } req, err := http.NewRequest(method, url, bodyReader) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } ts := time.Now().UnixNano() / 1000000 sign := c.calBusinessSign(ts) req.Header.Set("Accept", "*") req.Header.Set("Content-Type", "application/json") req.Header.Set("Access-Control-Allow-Origin", "*") req.Header.Set("Access-Control-Allow-Methods", "*") req.Header.Set("Access-Control-Allow-Headers", "*") req.Header.Set("mode", "no-cors") req.Header.Set("client_id", c.clientID) req.Header.Set("access_token", c.accessToken) req.Header.Set("sign", sign) req.Header.Set("t", strconv.FormatInt(ts, 10)) response, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer response.Body.Close() res, err := io.ReadAll(response.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } if response.StatusCode != http.StatusOK { return nil, fmt.Errorf("request failed with status code %d: %s", response.StatusCode, string(res)) } return res, nil } func(c *TuyaClient) InitToken() (err error) { url := fmt.Sprintf("https://%s/v1.0/token?grant_type=1", c.apiURL) c.accessToken = "" c.refreshToken = "" body, err := c.Request("GET", url, nil) if err != nil { return fmt.Errorf("failed to get token: %w", err) } var tokenResponse TokenResponse err = json.Unmarshal(body, &tokenResponse) if err != nil { return fmt.Errorf("failed to unmarshal token response: %w", err) } c.accessToken = tokenResponse.Result.AccessToken c.refreshToken = tokenResponse.Result.RefreshToken c.expireTime = tokenResponse.Result.ExpireTime return nil } func(c *TuyaClient) InitDevice() (err error) { url := fmt.Sprintf("https://%s/v1.0/users/%s/devices/%s/webrtc-configs", c.apiURL, c.uid, c.deviceID) body, err := c.Request("GET", url, nil) if err != nil { return fmt.Errorf("failed to get webrtc-configs: %w", err) } var webRTCConfigResponse WebRTCConfigResponse err = json.Unmarshal(body, &webRTCConfigResponse) if err != nil { return fmt.Errorf("failed to unmarshal webrtc-configs response: %w", err) } c.motoID = webRTCConfigResponse.Result.MotoID c.auth = webRTCConfigResponse.Result.Auth var skill Skill err = json.Unmarshal([]byte(webRTCConfigResponse.Result.Skill), &skill) if err != nil { return fmt.Errorf("failed to unmarshal skill: %w", err) } var audioDirection string if contains(webRTCConfigResponse.Result.AudioAttributes.CallMode, 2) && contains(webRTCConfigResponse.Result.AudioAttributes.HardwareCapability, 1) { audioDirection = core.DirectionSendRecv } else { audioDirection = core.DirectionRecvonly } c.medias = make([]*core.Media, 0) for _, audio := range skill.Audios { media := &core.Media{ Kind: core.KindAudio, Direction: audioDirection, Codecs: []*core.Codec{ { Name: "PCMU", ClockRate: uint32(audio.SampleRate), Channels: uint8(audio.Channels), }, }, } c.medias = append(c.medias, media) } // take only the first video codec video := skill.Videos[0] var name string switch video.CodecType { case 4: name = core.CodecH265 case 2: name = core.CodecH264 default: name = core.CodecH264 } media := &core.Media{ Kind: core.KindVideo, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{ { Name: name, ClockRate: uint32(video.SampleRate), PayloadType: 96, }, }, } c.medias = append(c.medias, media) iceServersBytes, err := json.Marshal(&webRTCConfigResponse.Result.P2PConfig.Ices) if err != nil { return fmt.Errorf("failed to marshal ICE servers: %w", err) } c.iceServers, err = webrtc.UnmarshalICEServers([]byte(iceServersBytes)) if err != nil { return fmt.Errorf("failed to unmarshal ICE servers: %w", err) } return nil } func(c *TuyaClient) LoadHubConfig() (config *OpenIoTHubConfig, err error) { url := fmt.Sprintf("https://%s/v2.0/open-iot-hub/access/config", c.apiURL) request := &OpenIoTHubConfigRequest{ UID: c.uid, UniqueID: uuid.New().String(), LinkType: "mqtt", Topics: "ipc", } var openIoTHubConfigResponse OpenIoTHubConfigResponse body, err := c.Request("POST", url, request) if err != nil { return nil, fmt.Errorf("failed to get OpenIoTHub config: %w", err) } err = json.Unmarshal(body, &openIoTHubConfigResponse) if err != nil { return nil, fmt.Errorf("failed to unmarshal OpenIoTHub config response: %w", err) } if !openIoTHubConfigResponse.Success { return nil, fmt.Errorf("failed to get OpenIoTHub config: %s", string(body)) } return &openIoTHubConfigResponse.Result, nil } func(c *TuyaClient) calBusinessSign(ts int64) string { data := fmt.Sprintf("%s%s%s%d", c.clientID, c.accessToken, c.secret, ts) val := md5.Sum([]byte(data)) res := fmt.Sprintf("%X", val) return res } func contains(slice []int, val int) bool { for _, item := range slice { if item == val { return true } } return false }