mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 15:47:06 +08:00
391 lines
9.2 KiB
Go
391 lines
9.2 KiB
Go
package wyze
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
|
)
|
|
|
|
const (
|
|
baseURLAuth = "https://auth-prod.api.wyze.com"
|
|
baseURLAPI = "https://api.wyzecam.com"
|
|
appName = "com.hualai.WyzeCam"
|
|
appVersion = "2.50.0"
|
|
)
|
|
|
|
type Cloud struct {
|
|
client *http.Client
|
|
apiKey string
|
|
keyID string
|
|
accessToken string
|
|
refreshToken string
|
|
phoneID string
|
|
openUserID string
|
|
cameras []*Camera
|
|
}
|
|
|
|
type Camera struct {
|
|
MAC string `json:"mac"`
|
|
P2PID string `json:"p2p_id"`
|
|
ENR string `json:"enr"`
|
|
IP string `json:"ip"`
|
|
Nickname string `json:"nickname"`
|
|
ProductModel string `json:"product_model"`
|
|
ProductType string `json:"product_type"`
|
|
DTLS int `json:"dtls"`
|
|
FirmwareVer string `json:"firmware_ver"`
|
|
IsOnline bool `json:"is_online"`
|
|
}
|
|
|
|
func (c *Camera) ModelName() string {
|
|
models := map[string]string{
|
|
"WYZEC1": "Wyze Cam v1",
|
|
"WYZEC1-JZ": "Wyze Cam v2",
|
|
"WYZE_CAKP2JFUS": "Wyze Cam v3",
|
|
"HL_CAM3P": "Wyze Cam v3 Pro",
|
|
"HL_CAM4": "Wyze Cam v4",
|
|
"WYZECP1_JEF": "Wyze Cam Pan",
|
|
"HL_PANP": "Wyze Cam Pan v2",
|
|
"HL_PAN3": "Wyze Cam Pan v3",
|
|
"WVOD1": "Wyze Video Doorbell",
|
|
"WVOD2": "Wyze Video Doorbell v2",
|
|
"AN_RSCW": "Wyze Video Doorbell Pro",
|
|
"GW_BE1": "Wyze Cam Floodlight",
|
|
"HL_WCO2": "Wyze Cam Outdoor",
|
|
"HL_CFL2": "Wyze Cam Floodlight v2",
|
|
"LD_CFP": "Wyze Battery Cam Pro",
|
|
}
|
|
if name, ok := models[c.ProductModel]; ok {
|
|
return name
|
|
}
|
|
return c.ProductModel
|
|
}
|
|
|
|
func NewCloud() *Cloud {
|
|
return &Cloud{
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
|
phoneID: generatePhoneID(),
|
|
}
|
|
}
|
|
|
|
func NewCloudWithAPIKey(apiKey, keyID string) *Cloud {
|
|
c := NewCloud()
|
|
c.apiKey = apiKey
|
|
c.keyID = keyID
|
|
return c
|
|
}
|
|
|
|
func generatePhoneID() string {
|
|
return core.RandString(16, 16) // 16 hex chars
|
|
}
|
|
|
|
type loginResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
UserID string `json:"user_id"`
|
|
MFAOptions []string `json:"mfa_options"`
|
|
SMSSessionID string `json:"sms_session_id"`
|
|
EmailSessionID string `json:"email_session_id"`
|
|
}
|
|
|
|
type apiError struct {
|
|
Code string `json:"code"`
|
|
ErrorCode int `json:"errorCode"`
|
|
Msg string `json:"msg"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
func (e *apiError) hasError() bool {
|
|
if e.Code == "1" || e.Code == "0" {
|
|
return false
|
|
}
|
|
if e.Code == "" && e.ErrorCode == 0 {
|
|
return false
|
|
}
|
|
return e.Code != "" || e.ErrorCode != 0
|
|
}
|
|
|
|
func (e *apiError) message() string {
|
|
if e.Msg != "" {
|
|
return e.Msg
|
|
}
|
|
return e.Description
|
|
}
|
|
|
|
func (e *apiError) code() string {
|
|
if e.Code != "" {
|
|
return e.Code
|
|
}
|
|
return fmt.Sprintf("%d", e.ErrorCode)
|
|
}
|
|
|
|
func (c *Cloud) Login(email, password string) error {
|
|
payload := map[string]string{
|
|
"email": strings.TrimSpace(email),
|
|
"password": hashPassword(password),
|
|
}
|
|
|
|
jsonData, _ := json.Marshal(payload)
|
|
|
|
req, err := http.NewRequest("POST", baseURLAuth+"/api/user/login", strings.NewReader(string(jsonData)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" && c.keyID != "" {
|
|
req.Header.Set("Apikey", c.apiKey)
|
|
req.Header.Set("Keyid", c.keyID)
|
|
req.Header.Set("User-Agent", "go2rtc")
|
|
} else {
|
|
req.Header.Set("X-API-Key", "WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ")
|
|
req.Header.Set("Phone-Id", c.phoneID)
|
|
req.Header.Set("User-Agent", "wyze_ios_"+appVersion)
|
|
}
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var errResp apiError
|
|
_ = json.Unmarshal(body, &errResp)
|
|
if errResp.hasError() {
|
|
return fmt.Errorf("wyze: login failed (code %s): %s", errResp.code(), errResp.message())
|
|
}
|
|
|
|
var result loginResponse
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return fmt.Errorf("wyze: failed to parse login response: %w", err)
|
|
}
|
|
|
|
if len(result.MFAOptions) > 0 {
|
|
return &AuthError{
|
|
Message: "MFA required",
|
|
NeedsMFA: true,
|
|
MFAType: strings.Join(result.MFAOptions, ","),
|
|
}
|
|
}
|
|
|
|
if result.AccessToken == "" {
|
|
return errors.New("wyze: no access token in response")
|
|
}
|
|
|
|
c.accessToken = result.AccessToken
|
|
c.refreshToken = result.RefreshToken
|
|
c.openUserID = result.UserID
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Cloud) LoginWithToken(accessToken, phoneID string) error {
|
|
c.accessToken = accessToken
|
|
if phoneID != "" {
|
|
c.phoneID = phoneID
|
|
}
|
|
_, err := c.GetCameraList()
|
|
return err
|
|
}
|
|
|
|
func (c *Cloud) Credentials() (phoneID, openUserID string) {
|
|
return c.phoneID, c.openUserID
|
|
}
|
|
|
|
func (c *Cloud) AccessToken() string {
|
|
return c.accessToken
|
|
}
|
|
|
|
type deviceListResponse struct {
|
|
Code string `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data struct {
|
|
DeviceList []deviceInfo `json:"device_list"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type deviceInfo struct {
|
|
MAC string `json:"mac"`
|
|
ENR string `json:"enr"`
|
|
Nickname string `json:"nickname"`
|
|
ProductModel string `json:"product_model"`
|
|
ProductType string `json:"product_type"`
|
|
FirmwareVer string `json:"firmware_ver"`
|
|
ConnState int `json:"conn_state"`
|
|
DeviceParams deviceParams `json:"device_params"`
|
|
}
|
|
|
|
type deviceParams struct {
|
|
P2PID string `json:"p2p_id"`
|
|
P2PType int `json:"p2p_type"`
|
|
IP string `json:"ip"`
|
|
DTLS int `json:"dtls"`
|
|
}
|
|
|
|
func (c *Cloud) GetCameraList() ([]*Camera, error) {
|
|
payload := map[string]any{
|
|
"access_token": c.accessToken,
|
|
"phone_id": c.phoneID,
|
|
"app_name": appName,
|
|
"app_ver": appName + "___" + appVersion,
|
|
"app_version": appVersion,
|
|
"phone_system_type": 1,
|
|
"sc": "9f275790cab94a72bd206c8876429f3c",
|
|
"sv": "9d74946e652647e9b6c9d59326aef104",
|
|
"ts": time.Now().UnixMilli(),
|
|
}
|
|
|
|
jsonData, _ := json.Marshal(payload)
|
|
|
|
req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/home_page/get_object_list", strings.NewReader(string(jsonData)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result deviceListResponse
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("wyze: failed to parse device list: %w", err)
|
|
}
|
|
|
|
if result.Code != "1" {
|
|
return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg)
|
|
}
|
|
|
|
c.cameras = nil
|
|
for _, dev := range result.Data.DeviceList {
|
|
if dev.ProductType != "Camera" {
|
|
continue
|
|
}
|
|
|
|
c.cameras = append(c.cameras, &Camera{
|
|
MAC: dev.MAC,
|
|
P2PID: dev.DeviceParams.P2PID,
|
|
ENR: dev.ENR,
|
|
IP: dev.DeviceParams.IP,
|
|
Nickname: dev.Nickname,
|
|
ProductModel: dev.ProductModel,
|
|
ProductType: dev.ProductType,
|
|
DTLS: dev.DeviceParams.DTLS,
|
|
FirmwareVer: dev.FirmwareVer,
|
|
IsOnline: dev.ConnState == 1,
|
|
})
|
|
}
|
|
|
|
return c.cameras, nil
|
|
}
|
|
|
|
func (c *Cloud) GetCamera(id string) (*Camera, error) {
|
|
if c.cameras == nil {
|
|
if _, err := c.GetCameraList(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
id = strings.ToUpper(id)
|
|
for _, cam := range c.cameras {
|
|
if strings.ToUpper(cam.MAC) == id || strings.EqualFold(cam.Nickname, id) {
|
|
return cam, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("wyze: camera not found: %s", id)
|
|
}
|
|
|
|
type p2pInfoResponse struct {
|
|
Code string `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
|
|
func (c *Cloud) GetP2PInfo(mac string) (map[string]any, error) {
|
|
payload := map[string]any{
|
|
"access_token": c.accessToken,
|
|
"phone_id": c.phoneID,
|
|
"device_mac": mac,
|
|
"app_name": appName,
|
|
"app_ver": appName + "___" + appVersion,
|
|
"app_version": appVersion,
|
|
"phone_system_type": 1,
|
|
"sc": "9f275790cab94a72bd206c8876429f3c",
|
|
"sv": "9d74946e652647e9b6c9d59326aef104",
|
|
"ts": time.Now().UnixMilli(),
|
|
}
|
|
|
|
jsonData, _ := json.Marshal(payload)
|
|
|
|
req, err := http.NewRequest("POST", baseURLAPI+"/app/v2/device/get_iotc_info", strings.NewReader(string(jsonData)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result p2pInfoResponse
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if result.Code != "1" {
|
|
return nil, fmt.Errorf("wyze: API error: %s - %s", result.Code, result.Msg)
|
|
}
|
|
|
|
return result.Data, nil
|
|
}
|
|
|
|
type AuthError struct {
|
|
Message string `json:"message"`
|
|
NeedsMFA bool `json:"needs_mfa,omitempty"`
|
|
MFAType string `json:"mfa_type,omitempty"`
|
|
}
|
|
|
|
func (e *AuthError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
func hashPassword(password string) string {
|
|
encoded := strings.TrimSpace(password)
|
|
if strings.HasPrefix(strings.ToLower(encoded), "md5:") {
|
|
return encoded[4:]
|
|
}
|
|
for range 3 {
|
|
hash := md5.Sum([]byte(encoded))
|
|
encoded = hex.EncodeToString(hash[:])
|
|
}
|
|
return encoded
|
|
}
|