refactor, simplify api, add support for email/password auth
This commit is contained in:
@@ -568,58 +568,38 @@ Tested: KD110, KC200, KC401, KC420WS, EC71.
|
|||||||
|
|
||||||
#### Source: Tuya
|
#### Source: Tuya
|
||||||
|
|
||||||
[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Open API`.
|
[Tuya](https://www.tuya.com/) proprietary camera protocol with **two way audio** support. Go2rtc supports `Cloud API` and `Tuya API`.
|
||||||
|
|
||||||
The `Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Open API` does not require a cloud project and the cameras can be added through the interface via QR code (user code required), but it does not support webrtc mode and two way audio.
|
The `Cloud API` requires setting up a cloud project in the Tuya Developer Platform to retrieve the required credentials. The `Tuya API` does not require a cloud project and the cameras can be added through the interface via email/password.
|
||||||
|
|
||||||
**Cloud API**:
|
**Cloud API**:
|
||||||
- Obtain `device_id`, `client_id`, `client_secret`, and `uid` (if using `mode=webrtc`) from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/).
|
- Obtain `device_id`, `client_id`, `client_secret`, and `uid` from [Tuya IoT Platform](https://iot.tuya.com/). [Here's a guide](https://xzetsubou.github.io/hass-localtuya/cloud_api/).
|
||||||
|
|
||||||
**Open API**:
|
**Open API**:
|
||||||
- To get your user code, open the Tuya Smart app or Smart Life app and go to `Profile` > `Settings` > `Account and Security` > `User Code`
|
- Smart Life accounts are not supported, you need to create a Tuya account. If the cameras are already added to the Smart Life app, you need to remove them and add them again to the Tuya Smart app.
|
||||||
- Open the Go2rtc interface and go to `Add` > `Tuya` and enter your `User Code` in the `User Code` field. Click on `Generate QR Code` and scan it with the Tuya Smart app or Smart Life app. After scanning, click on `Login`. All cameras in your home (not shared ones) will be listed in the Go2rtc interface. Copy/Paste stream URLs to your `go2rtc.yaml` file.
|
|
||||||
|
|
||||||
**Configuring the stream:**
|
**Configuring the stream:**
|
||||||
- Use `mode` parameter to select the stream type (not all cameras support all modes):
|
- Use `resolution` parameter to select the stream (not all cameras support `hd` stream through WebRTC even if the camera has it):
|
||||||
- `webrtc` - WebRTC stream _(default for `Cloud API`)_
|
|
||||||
- `rtsp` - RTSP stream _(default for `Open API`)_
|
|
||||||
- `hls` - HLS stream
|
|
||||||
- `flv` - FLV stream _(only available for `Open API`)_
|
|
||||||
- `rtmp` - RTMP stream _(only available for `Open API`)_
|
|
||||||
|
|
||||||
- Use `resolution` parameter to select the stream (only available for `Cloud API` and not all cameras support `hd` stream through WebRTC even if the camera has it):
|
|
||||||
- `hd` - HD stream (default)
|
- `hd` - HD stream (default)
|
||||||
- `sd` - SD stream
|
- `sd` - SD stream
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
streams:
|
streams:
|
||||||
# Cloud API: WebRTC stream
|
# Cloud API: WebRTC main stream
|
||||||
tuya_webrtc:
|
tuya_webrtc:
|
||||||
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX
|
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX
|
||||||
|
|
||||||
# Cloud API: WebRTC stream (same as above)
|
|
||||||
tuya_webrtc_2:
|
|
||||||
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&mode=webrtc
|
|
||||||
|
|
||||||
# Cloud API: WebRTC stream (HD)
|
# Cloud API: WebRTC sub stream
|
||||||
tuya_webrtc_hd:
|
|
||||||
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=hd
|
|
||||||
|
|
||||||
# Cloud API: WebRTC stream (SD)
|
|
||||||
tuya_webrtc_sd:
|
tuya_webrtc_sd:
|
||||||
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd
|
- tuya://openapi.tuyaus.com?device_id=XXX&uid=XXX&client_id=XXX&client_secret=XXX&resolution=sd
|
||||||
|
|
||||||
# Cloud API: RTSP stream when available (no "uid" required)
|
|
||||||
tuya_rtsp:
|
|
||||||
- tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=rtsp
|
|
||||||
|
|
||||||
# Cloud API: HLS stream when available (no "uid" required)
|
|
||||||
tuya_hls:
|
|
||||||
- tuya://openapi.tuyaus.com?device_id=XXX&client_id=XXX&client_secret=XXX&mode=hls
|
|
||||||
|
|
||||||
# Open API: RTSP stream
|
# Tuya API: WebRTC main stream
|
||||||
tuya_openapi:
|
tuya:
|
||||||
- tuya://apigw.tuyaeu.com?device_id=XXX&terminal_id=XXX&token=XXX&uid=XXX
|
- tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX
|
||||||
|
|
||||||
|
# Tuya API: WebRTC sub stream
|
||||||
|
tuya:
|
||||||
|
- tuya://protect-us.ismartlife.me?device_id=XXX&email=XXX&password=XXX&resolution=sd
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Source: GoPro
|
#### Source: GoPro
|
||||||
|
|||||||
+158
-124
@@ -1,13 +1,13 @@
|
|||||||
package tuya
|
package tuya
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/api"
|
"github.com/AlexxIT/go2rtc/internal/api"
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||||
@@ -15,8 +15,6 @@ import (
|
|||||||
"github.com/AlexxIT/go2rtc/pkg/tuya"
|
"github.com/AlexxIT/go2rtc/pkg/tuya"
|
||||||
)
|
)
|
||||||
|
|
||||||
var users = make(map[string]tuya.LoginResponse)
|
|
||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
streams.HandleFunc("tuya", func(source string) (core.Producer, error) {
|
streams.HandleFunc("tuya", func(source string) (core.Producer, error) {
|
||||||
return tuya.Dial(source)
|
return tuya.Dial(source)
|
||||||
@@ -27,74 +25,41 @@ func Init() {
|
|||||||
|
|
||||||
func apiTuya(w http.ResponseWriter, r *http.Request) {
|
func apiTuya(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
userCode := query.Get("user_code")
|
region := query.Get("region")
|
||||||
token := query.Get("token")
|
email := query.Get("email")
|
||||||
|
password := query.Get("password")
|
||||||
|
|
||||||
if userCode == "" {
|
if email == "" || password == "" || region == "" {
|
||||||
http.Error(w, "user_code is required", http.StatusBadRequest)
|
http.Error(w, "email, password and region are required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var auth *tuya.LoginResponse
|
var tuyaRegion *tuya.Region
|
||||||
if loginResponse, ok := users[userCode]; ok {
|
for _, r := range tuya.AvailableRegions {
|
||||||
expireTime := loginResponse.Timestamp + loginResponse.Result.ExpireTime
|
if r.Host == region {
|
||||||
|
tuyaRegion = &r
|
||||||
if expireTime > time.Now().Unix() {
|
break
|
||||||
auth = &loginResponse
|
|
||||||
} else {
|
|
||||||
delete(users, userCode)
|
|
||||||
token = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if auth == nil && token == "" {
|
if tuyaRegion == nil {
|
||||||
qrCode, err := getQRCode(userCode)
|
http.Error(w, fmt.Sprintf("invalid region: %s", region), http.StatusBadRequest)
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// response qrCode
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"qrCode": qrCode,
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if auth == nil && token != "" {
|
httpClient := tuya.CreateHTTPClientWithSession()
|
||||||
authResponse, err := login(userCode, token)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
auth = authResponse
|
_, err := login(httpClient, tuyaRegion.Host, email, password, tuyaRegion.Continent)
|
||||||
}
|
|
||||||
|
|
||||||
if auth == nil {
|
|
||||||
http.Error(w, "failed to get auth", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenInfo := tuya.TokenInfo{
|
|
||||||
AccessToken: auth.Result.AccessToken,
|
|
||||||
ExpireTime: auth.Timestamp + auth.Result.ExpireTime,
|
|
||||||
RefreshToken: auth.Result.RefreshToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenInfoBase64, err := tuya.ToBase64(&tokenInfo)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("login failed: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tuyaAPI, err := tuya.NewTuyaOpenApiClient(
|
tuyaAPI, err := tuya.NewTuyaApiClient(
|
||||||
strings.Replace(auth.Result.Endpoint, "https://", "", 1),
|
httpClient,
|
||||||
auth.Result.UID,
|
tuyaRegion.Host,
|
||||||
"",
|
email,
|
||||||
auth.Result.TerminalID,
|
password,
|
||||||
tokenInfo,
|
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,25 +68,52 @@ func apiTuya(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
devices, err := tuyaAPI.GetAllDevices()
|
var devices []tuya.Device
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
homes, _ := tuyaAPI.GetHomeList()
|
||||||
|
if homes != nil && len(homes.Result) > 0 {
|
||||||
|
for _, home := range homes.Result {
|
||||||
|
roomList, err := tuyaAPI.GetRoomList(strconv.Itoa(home.Gid))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, room := range roomList.Result {
|
||||||
|
for _, device := range room.DeviceList {
|
||||||
|
if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) {
|
||||||
|
devices = append(devices, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedHomes, _ := tuyaAPI.GetSharedHomeList()
|
||||||
|
if sharedHomes != nil && len(sharedHomes.Result.SecurityWebCShareInfoList) > 0 {
|
||||||
|
for _, sharedHome := range sharedHomes.Result.SecurityWebCShareInfoList {
|
||||||
|
for _, device := range sharedHome.DeviceInfoList {
|
||||||
|
if (device.Category == "sp" || device.Category == "dghsxj") && !containsDevice(devices, device.DeviceId) {
|
||||||
|
devices = append(devices, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(devices) == 0 {
|
||||||
|
http.Error(w, "no cameras found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []*api.Source
|
var items []*api.Source
|
||||||
for _, device := range devices {
|
for _, device := range devices {
|
||||||
cleanQuery := url.Values{}
|
cleanQuery := url.Values{}
|
||||||
cleanQuery.Set("uid", auth.Result.UID)
|
cleanQuery.Set("device_id", device.DeviceId)
|
||||||
cleanQuery.Set("token", tokenInfoBase64)
|
cleanQuery.Set("email", email)
|
||||||
cleanQuery.Set("terminal_id", auth.Result.TerminalID)
|
cleanQuery.Set("password", password)
|
||||||
cleanQuery.Set("device_id", device.ID)
|
url := fmt.Sprintf("tuya://%s?%s", tuyaRegion.Host, cleanQuery.Encode())
|
||||||
|
|
||||||
endpoint := strings.Replace(auth.Result.Endpoint, "https://", "tuya://", 1)
|
|
||||||
url := fmt.Sprintf("%s?%s", endpoint, cleanQuery.Encode())
|
|
||||||
|
|
||||||
items = append(items, &api.Source{
|
items = append(items, &api.Source{
|
||||||
Name: device.Name,
|
Name: device.DeviceName,
|
||||||
URL: url,
|
URL: url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -129,86 +121,128 @@ func apiTuya(w http.ResponseWriter, r *http.Request) {
|
|||||||
api.ResponseSources(w, items)
|
api.ResponseSources(w, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func login(userCode string, qrCode string) (*tuya.LoginResponse, error) {
|
func login(client *http.Client, serverHost, email, password, countryCode string) (*tuya.LoginResult, error) {
|
||||||
url := fmt.Sprintf("https://%s/v1.0/m/life/home-assistant/qrcode/tokens/%s?clientid=%s&usercode=%s", tuya.TUYA_HOST, qrCode, tuya.TUYA_CLIENT_ID, userCode)
|
tokenResp, err := getLoginToken(client, serverHost, email, countryCode)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{
|
encryptedPassword, err := tuya.EncryptPassword(password, tokenResp.Result.PbKey)
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to encrypt password: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
|
||||||
|
|
||||||
res, err := io.ReadAll(response.Body)
|
var loginResp *tuya.PasswordLoginResponse
|
||||||
|
var url string
|
||||||
|
|
||||||
|
loginReq := tuya.PasswordLoginRequest{
|
||||||
|
CountryCode: countryCode,
|
||||||
|
Passwd: encryptedPassword,
|
||||||
|
Token: tokenResp.Result.Token,
|
||||||
|
IfEncrypt: 1,
|
||||||
|
Options: `{"group":1}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if tuya.IsEmailAddress(email) {
|
||||||
|
url = fmt.Sprintf("https://%s/api/private/email/login", serverHost)
|
||||||
|
loginReq.Email = email
|
||||||
|
} else {
|
||||||
|
url = fmt.Sprintf("https://%s/api/private/phone/login", serverHost)
|
||||||
|
loginReq.Mobile = email
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp, err = performLogin(client, url, loginReq, serverHost)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if !loginResp.Success {
|
||||||
return nil, fmt.Errorf("failed to get QR code: %s", string(res))
|
return nil, errors.New(loginResp.ErrorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
var loginResponse tuya.LoginResponse
|
return &loginResp.Result, nil
|
||||||
err = json.Unmarshal(res, &loginResponse)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !loginResponse.Success {
|
|
||||||
return nil, fmt.Errorf("failed to login: %s", loginResponse.Msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
users[userCode] = loginResponse
|
|
||||||
|
|
||||||
return &loginResponse, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getQRCode(userCode string) (string, error) {
|
func getLoginToken(client *http.Client, serverHost, username, countryCode string) (*tuya.LoginTokenResponse, error) {
|
||||||
url := fmt.Sprintf("https://%s/v1.0/m/life/home-assistant/qrcode/tokens?clientid=%s&schema=%s&usercode=%s", tuya.TUYA_HOST, tuya.TUYA_CLIENT_ID, tuya.TUYA_SCHEMA, userCode)
|
url := fmt.Sprintf("https://%s/api/login/token", serverHost)
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", url, nil)
|
tokenReq := tuya.LoginTokenRequest{
|
||||||
|
CountryCode: countryCode,
|
||||||
|
Username: username,
|
||||||
|
IsUid: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(tokenReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "text/plain")
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
|
||||||
|
|
||||||
res, err := io.ReadAll(response.Body)
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
req.Header.Set("Origin", fmt.Sprintf("https://%s", serverHost))
|
||||||
|
req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost))
|
||||||
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var tokenResp tuya.LoginTokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if !tokenResp.Success {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var qrResponse tuya.QRResponse
|
return &tokenResp, nil
|
||||||
err = json.Unmarshal(res, &qrResponse)
|
}
|
||||||
if err != nil {
|
|
||||||
return "", err
|
func performLogin(client *http.Client, url string, loginReq tuya.PasswordLoginRequest, serverHost string) (*tuya.PasswordLoginResponse, error) {
|
||||||
}
|
jsonData, err := json.Marshal(loginReq)
|
||||||
|
if err != nil {
|
||||||
if !qrResponse.Success {
|
return nil, err
|
||||||
return "", fmt.Errorf("failed to get QR code: %s", qrResponse.Msg)
|
}
|
||||||
}
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
return qrResponse.Result.Code, nil
|
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", serverHost))
|
||||||
|
req.Header.Set("Referer", fmt.Sprintf("https://%s/login", serverHost))
|
||||||
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var loginResp tuya.PasswordLoginResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &loginResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsDevice(devices []tuya.Device, deviceID string) bool {
|
||||||
|
for _, device := range devices {
|
||||||
|
if device.DeviceId == deviceID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-29
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
|
||||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||||
"github.com/pion/rtp"
|
"github.com/pion/rtp"
|
||||||
@@ -50,38 +49,29 @@ func Dial(rawURL string) (core.Producer, error) {
|
|||||||
|
|
||||||
query := u.Query()
|
query := u.Query()
|
||||||
|
|
||||||
// Open API
|
// Tuya API
|
||||||
tokenInfo := query.Get("token")
|
email := query.Get("email")
|
||||||
terminalId := query.Get("terminal_id")
|
password := query.Get("password")
|
||||||
|
|
||||||
// Cloud API
|
// Cloud API
|
||||||
|
uid := query.Get("uid")
|
||||||
clientId := query.Get("client_id")
|
clientId := query.Get("client_id")
|
||||||
clientSecret := query.Get("client_secret")
|
clientSecret := query.Get("client_secret")
|
||||||
|
|
||||||
// Shared params
|
// Shared params
|
||||||
deviceId := query.Get("device_id")
|
deviceId := query.Get("device_id")
|
||||||
uid := query.Get("uid")
|
|
||||||
|
|
||||||
// Stream params
|
// Stream params
|
||||||
streamResolution := query.Get("resolution")
|
streamResolution := query.Get("resolution")
|
||||||
streamMode := query.Get("mode")
|
|
||||||
|
|
||||||
useOpenApi := deviceId != "" && uid != "" && tokenInfo != "" && terminalId != ""
|
useTuyaApi := deviceId != "" && email != "" && password != ""
|
||||||
useCloudApi := deviceId != "" && ((streamMode == "webrtc" || streamMode == "") && uid != "") && clientId != "" && clientSecret != ""
|
useCloudApi := deviceId != "" && uid != "" && clientId != "" && clientSecret != ""
|
||||||
|
|
||||||
if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") {
|
if streamResolution == "" || (streamResolution != "hd" && streamResolution != "sd") {
|
||||||
streamResolution = "hd"
|
streamResolution = "hd"
|
||||||
}
|
}
|
||||||
|
|
||||||
if streamMode == "" || (streamMode != "rtsp" && streamMode != "hls" && streamMode != "flv" && streamMode != "rtmp" && streamMode != "webrtc") {
|
if !useTuyaApi && !useCloudApi {
|
||||||
if useOpenApi {
|
|
||||||
streamMode = "rtsp"
|
|
||||||
} else {
|
|
||||||
streamMode = "webrtc"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !useOpenApi && !useCloudApi {
|
|
||||||
return nil, errors.New("tuya: wrong query params")
|
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)),
|
handlers: make(map[uint32]func(*rtp.Packet)),
|
||||||
}
|
}
|
||||||
|
|
||||||
if useOpenApi {
|
if useTuyaApi {
|
||||||
if client.api, err = NewTuyaOpenApiClient(u.Hostname(), uid, deviceId, terminalId, tokenInfo, streamMode); err != nil {
|
if client.api, err = NewTuyaApiClient(nil, u.Hostname(), email, password, deviceId); err != nil {
|
||||||
return nil, fmt.Errorf("tuya: %w", err)
|
return nil, fmt.Errorf("tuya: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
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 {
|
if err := client.api.Init(); err != nil {
|
||||||
return nil, fmt.Errorf("tuya: %w", err)
|
return nil, fmt.Errorf("tuya: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -68,24 +69,26 @@ type OpenIoTHubConfigResponse struct {
|
|||||||
|
|
||||||
type TuyaCloudApiClient struct {
|
type TuyaCloudApiClient struct {
|
||||||
TuyaClient
|
TuyaClient
|
||||||
|
uid string
|
||||||
clientId string
|
clientId string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
|
accessToken string
|
||||||
|
refreshToken string
|
||||||
refreshingToken bool
|
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)
|
mqttClient := NewTuyaMqttClient(deviceId)
|
||||||
|
|
||||||
client := &TuyaCloudApiClient{
|
client := &TuyaCloudApiClient{
|
||||||
TuyaClient: TuyaClient{
|
TuyaClient: TuyaClient{
|
||||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||||
mqtt: mqttClient,
|
mqtt: mqttClient,
|
||||||
uid: uid,
|
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
streamMode: streamMode,
|
|
||||||
expireTime: 0,
|
expireTime: 0,
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
},
|
},
|
||||||
|
uid: uid,
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
refreshingToken: false,
|
refreshingToken: false,
|
||||||
@@ -140,7 +143,7 @@ func (c *TuyaCloudApiClient) GetStreamUrl(streamType string) (streamUrl string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !allocResponse.Success {
|
if !allocResponse.Success {
|
||||||
return "", fmt.Errorf(allocResponse.Msg)
|
return "", errors.New(allocResponse.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return allocResponse.Result.URL, nil
|
return allocResponse.Result.URL, nil
|
||||||
@@ -175,7 +178,7 @@ func (c *TuyaCloudApiClient) initToken() (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !tokenResponse.Success {
|
if !tokenResponse.Success {
|
||||||
return fmt.Errorf(tokenResponse.Msg)
|
return errors.New(tokenResponse.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.accessToken = tokenResponse.Result.AccessToken
|
c.accessToken = tokenResponse.Result.AccessToken
|
||||||
|
|||||||
@@ -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
@@ -1,72 +1,69 @@
|
|||||||
package tuya
|
package tuya
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"crypto/md5"
|
||||||
"encoding/json"
|
cryptoRand "crypto/rand"
|
||||||
"fmt"
|
"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 {
|
func EncryptPassword(password, pbKey string) (string, error) {
|
||||||
if content == nil {
|
// Hash password with MD5
|
||||||
return "{}"
|
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 {
|
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) {
|
func IsEmailAddress(input string) bool {
|
||||||
jsonData, err := json.Marshal(tokenInfo)
|
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||||
if err != nil {
|
return emailRegex.MatchString(input)
|
||||||
return "", fmt.Errorf("error marshalling token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded := base64.URLEncoding.EncodeToString(jsonData)
|
|
||||||
|
|
||||||
return encoded, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromBase64(encodedTokenInfo string) (*TokenInfo, error) {
|
func CreateHTTPClientWithSession() *http.Client {
|
||||||
jsonData, err := base64.URLEncoding.DecodeString(encodedTokenInfo)
|
jar, err := cookiejar.New(&cookiejar.Options{
|
||||||
|
PublicSuffixList: publicsuffix.List,
|
||||||
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error decoding token: %v", err)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenInfo TokenInfo
|
return &http.Client{
|
||||||
err = json.Unmarshal(jsonData, &tokenInfo)
|
Timeout: 30 * time.Second,
|
||||||
if err != nil {
|
Jar: jar,
|
||||||
return nil, fmt.Errorf("error unmarshalling token: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = ©OfV
|
|
||||||
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
@@ -26,17 +26,13 @@ type TuyaAPI interface {
|
|||||||
type TuyaClient struct {
|
type TuyaClient struct {
|
||||||
TuyaAPI
|
TuyaAPI
|
||||||
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
mqtt *TuyaMqttClient
|
mqtt *TuyaMqttClient
|
||||||
streamMode string
|
baseUrl string
|
||||||
baseUrl string
|
expireTime int64
|
||||||
accessToken string
|
deviceId string
|
||||||
refreshToken string
|
skill *Skill
|
||||||
expireTime int64
|
iceServers []pionWebrtc.ICEServer
|
||||||
deviceId string
|
|
||||||
uid string
|
|
||||||
skill *Skill
|
|
||||||
iceServers []pionWebrtc.ICEServer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioAttributes struct {
|
type AudioAttributes struct {
|
||||||
@@ -44,11 +40,11 @@ type AudioAttributes struct {
|
|||||||
HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker
|
HardwareCapability []int `json:"hardware_capability"` // 1 = mic, 2 = speaker
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenApiICE struct {
|
type ICEServer struct {
|
||||||
Urls string `json:"urls"`
|
Urls string `json:"urls"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username,omitempty"`
|
||||||
Credential string `json:"credential"`
|
Credential string `json:"credential,omitempty"`
|
||||||
TTL int `json:"ttl"`
|
TTL int `json:"ttl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebICE struct {
|
type WebICE struct {
|
||||||
@@ -58,7 +54,7 @@ type WebICE struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type P2PConfig struct {
|
type P2PConfig struct {
|
||||||
Ices []OpenApiICE `json:"ices"`
|
Ices []ICEServer `json:"ices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioSkill struct {
|
type AudioSkill struct {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+15
-73
@@ -28,7 +28,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<script src="https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
@@ -283,14 +282,17 @@
|
|||||||
|
|
||||||
<button id="tuya">Tuya</button>
|
<button id="tuya">Tuya</button>
|
||||||
<div class="module">
|
<div class="module">
|
||||||
<p style="font-size: 0.9rem">Attention: Cameras added through QR Code does not support webrtc mode!</p>
|
<form id="tuya-credentials-form" style="margin-bottom: 10px">
|
||||||
<form id="tuya-qr-form" style="margin-bottom: 10px">
|
<select name="region">
|
||||||
<input id="tuya-user-code" type="text" name="user_code" placeholder="User Code">
|
<option value="protect-eu.ismartlife.me">EU Central</option>
|
||||||
<input type="submit" value="Generate QR Code">
|
<option value="protect-we.ismartlife.me">EU East</option>
|
||||||
</form>
|
<option value="protect-us.ismartlife.me">US West</option>
|
||||||
|
<option value="protect-ue.ismartlife.me">US East</option>
|
||||||
<form id="tuya-login-form" style="margin-bottom: 10px; display: none">
|
<option value="protect.ismartlife.me">China</option>
|
||||||
<div id="qrcode"></div>
|
<option value="protect-in.ismartlife.me">India</option>
|
||||||
|
</select>
|
||||||
|
<input type="email" name="email" placeholder="email">
|
||||||
|
<input type="password" name="password" placeholder="password">
|
||||||
<input type="submit" value="Login">
|
<input type="submit" value="Login">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -298,91 +300,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('tuya').addEventListener('click', async ev => {
|
document.getElementById('tuya').addEventListener('click', async ev => {
|
||||||
document.getElementById('qrcode').innerHTML = '';
|
|
||||||
ev.target.nextElementSibling.style.display = 'block';
|
ev.target.nextElementSibling.style.display = 'block';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('tuya-qr-form').addEventListener('submit', async ev => {
|
document.getElementById('tuya-credentials-form').addEventListener('submit', async ev => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
const table = document.getElementById('tuya-table');
|
const table = document.getElementById('tuya-table');
|
||||||
|
|
||||||
const query = new URLSearchParams(new FormData(ev.target));
|
|
||||||
if (!query.has('user_code')) {
|
|
||||||
table.innerText = 'User Code is required';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.innerText = 'loading...';
|
table.innerText = 'loading...';
|
||||||
|
|
||||||
|
const query = new URLSearchParams(new FormData(ev.target));
|
||||||
const url = new URL('api/tuya?' + query.toString(), location.href);
|
const url = new URL('api/tuya?' + query.toString(), location.href);
|
||||||
|
|
||||||
const r = await fetch(url, {cache: 'no-cache'});
|
|
||||||
|
|
||||||
if (!r.ok) {
|
|
||||||
table.innerText = await r.text()
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await r.json();
|
|
||||||
|
|
||||||
new QRCode(document.getElementById('qrcode'), {
|
|
||||||
text: 'tuyaSmart--qrLogin?token=' + response.qrCode,
|
|
||||||
width: 256,
|
|
||||||
height: 256,
|
|
||||||
colorDark: '#000000',
|
|
||||||
colorLight: '#ffffff',
|
|
||||||
correctLevel: QRCode.CorrectLevel.Q
|
|
||||||
});
|
|
||||||
|
|
||||||
table.innerText = 'Scan the QR Code with the Tuya/Smart Life app and click "Login"';
|
|
||||||
|
|
||||||
document.getElementById('tuya-qr-form').style.display = 'none';
|
|
||||||
document.getElementById('tuya-login-form').style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('tuya-login-form').addEventListener('submit', async ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const table = document.getElementById('tuya-table');
|
|
||||||
|
|
||||||
const qrcodeEl = document.getElementById('qrcode');
|
|
||||||
const userCode = document.getElementById('tuya-user-code')?.value;
|
|
||||||
const qrcode = qrcodeEl?.title;
|
|
||||||
|
|
||||||
if (!userCode) {
|
|
||||||
table.innerText = 'User Code is required! Reload the page and generate a QR Code first.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!qrcode) {
|
|
||||||
table.innerText = 'QR Code is required! Please generate a QR Code first and scan it with the Tuya/Smart Life app.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.innerText = 'loading...';
|
|
||||||
|
|
||||||
const url = new URL('api/tuya', location.href);
|
|
||||||
url.searchParams.set('user_code', userCode);
|
|
||||||
url.searchParams.set('token', qrcode.replace('tuyaSmart--qrLogin?token=', ''));
|
|
||||||
|
|
||||||
const r = await fetch(url, {cache: 'no-cache'});
|
const r = await fetch(url, {cache: 'no-cache'});
|
||||||
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
table.innerText = await r.text();
|
table.innerText = (await r.text()) || 'Unknown error';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.innerText = '';
|
|
||||||
|
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
|
|
||||||
document.getElementById('tuya-qr-form').style.display = 'none';
|
table.innerText = '';
|
||||||
document.getElementById('tuya-login-form').style.display = 'none';
|
|
||||||
|
|
||||||
drawTable(table, data);
|
drawTable(table, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button id="gopro">GoPro</button>
|
<button id="gopro">GoPro</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user