refactor, simplify api, add support for email/password auth

This commit is contained in:
seydx
2025-05-26 18:29:31 +02:00
parent 42b7eea852
commit 5be5d9247c
10 changed files with 857 additions and 941 deletions
+10 -29
View File
@@ -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)
}
+8 -5
View File
@@ -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
-134
View File
@@ -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)
}
+50 -53
View File
@@ -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 = &copyOfV
default:
return nil, fmt.Errorf("invalid type: %T", v)
}
if tokenInfo == nil {
return nil, fmt.Errorf("token info is nil")
}
return tokenInfo, nil
}
+12 -16
View File
@@ -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 {
-473
View File
@@ -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
}
+590
View File
@@ -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
}