998c85d6f5
- support qr code auth - support resolution change - support h265 - refactor code
474 lines
11 KiB
Go
474 lines
11 KiB
Go
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(500 * time.Millisecond)
|
|
deviceMap := make(map[string]Device)
|
|
|
|
for i, home := range homes {
|
|
if i > 0 {
|
|
time.Sleep(300 * 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
|
|
}
|