mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 23:57:20 +08:00
357 lines
7.8 KiB
Go
357 lines
7.8 KiB
Go
package xiaomi
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/AlexxIT/go2rtc/internal/api"
|
|
"github.com/AlexxIT/go2rtc/internal/app"
|
|
"github.com/AlexxIT/go2rtc/internal/streams"
|
|
"github.com/AlexxIT/go2rtc/pkg/core"
|
|
"github.com/AlexxIT/go2rtc/pkg/xiaomi"
|
|
"github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto"
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
func Init() {
|
|
var v struct {
|
|
Cfg map[string]string `yaml:"xiaomi"`
|
|
}
|
|
app.LoadConfig(&v)
|
|
|
|
tokens = v.Cfg
|
|
|
|
log = app.GetLogger("xiaomi")
|
|
|
|
streams.HandleFunc("xiaomi", func(rawURL string) (core.Producer, error) {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if u.User != nil {
|
|
rawURL, err = getCameraURL(u)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
log.Debug().Msgf("xiaomi: dial %s", rawURL)
|
|
|
|
return xiaomi.Dial(rawURL)
|
|
})
|
|
|
|
api.HandleFunc("api/xiaomi", apiXiaomi)
|
|
}
|
|
|
|
var log zerolog.Logger
|
|
|
|
var tokens map[string]string
|
|
var clouds map[string]*xiaomi.Cloud
|
|
var cloudsMu sync.Mutex
|
|
|
|
func getCloud(userID string) (*xiaomi.Cloud, error) {
|
|
cloudsMu.Lock()
|
|
defer cloudsMu.Unlock()
|
|
|
|
if cloud := clouds[userID]; cloud != nil {
|
|
return cloud, nil
|
|
}
|
|
|
|
cloud := xiaomi.NewCloud(AppXiaomiHome)
|
|
if err := cloud.LoginWithToken(userID, tokens[userID]); err != nil {
|
|
return nil, err
|
|
}
|
|
if clouds == nil {
|
|
clouds = map[string]*xiaomi.Cloud{userID: cloud}
|
|
} else {
|
|
clouds[userID] = cloud
|
|
}
|
|
return cloud, nil
|
|
}
|
|
|
|
func cloudRequest(userID, region, apiURL, params string) ([]byte, error) {
|
|
cloud, err := getCloud(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cloud.Request(GetBaseURL(region), apiURL, params, nil)
|
|
}
|
|
|
|
func cloudUserRequest(user *url.Userinfo, apiURL, params string) ([]byte, error) {
|
|
userID := user.Username()
|
|
region, _ := user.Password()
|
|
return cloudRequest(userID, region, apiURL, params)
|
|
}
|
|
|
|
func getCameraURL(url *url.URL) (string, error) {
|
|
model := url.Query().Get("model")
|
|
|
|
// It is not known which models need to be awakened.
|
|
// Probably all the doorbells and all the battery cameras.
|
|
if strings.Contains(model, ".cateye.") {
|
|
_ = wakeUpCamera(url)
|
|
}
|
|
|
|
// The getMissURL request has a fallback to getP2PURL.
|
|
// But for known models we can save one request to the cloud.
|
|
if xiaomi.IsLegacy(model) {
|
|
return getLegacyURL(url)
|
|
}
|
|
return getMissURL(url)
|
|
}
|
|
|
|
func getLegacyURL(url *url.URL) (string, error) {
|
|
query := url.Query()
|
|
|
|
clientPublic, clientPrivate, err := crypto.GenerateKey()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
params := fmt.Sprintf(`{"did":"%s","toSignAppData":"%x"}`, query.Get("did"), clientPublic)
|
|
|
|
userID := url.User.Username()
|
|
region, _ := url.User.Password()
|
|
res, err := cloudRequest(userID, region, "/device/devicepass", params)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var v struct {
|
|
UID string `json:"p2p_id"`
|
|
Password string `json:"password"`
|
|
PublicKey string `json:"p2p_dev_public_key"`
|
|
Sign string `json:"signForAppData"`
|
|
}
|
|
if err = json.Unmarshal(res, &v); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query.Set("uid", v.UID)
|
|
|
|
if v.Sign != "" {
|
|
query.Set("client_public", hex.EncodeToString(clientPublic))
|
|
query.Set("client_private", hex.EncodeToString(clientPrivate))
|
|
query.Set("device_public", v.PublicKey)
|
|
query.Set("sign", v.Sign)
|
|
} else {
|
|
query.Set("password", v.Password)
|
|
}
|
|
|
|
url.RawQuery = query.Encode()
|
|
return url.String(), nil
|
|
}
|
|
|
|
func getMissURL(url *url.URL) (string, error) {
|
|
clientPublic, clientPrivate, err := crypto.GenerateKey()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query := url.Query()
|
|
params := fmt.Sprintf(
|
|
`{"app_pubkey":"%x","did":"%s","support_vendors":"TUTK_CS2_MTP"}`,
|
|
clientPublic, query.Get("did"),
|
|
)
|
|
|
|
res, err := cloudUserRequest(url.User, "/v2/device/miss_get_vendor", params)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "no available vendor support") {
|
|
return getLegacyURL(url)
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
var v struct {
|
|
Vendor struct {
|
|
ID byte `json:"vendor"`
|
|
Params struct {
|
|
UID string `json:"p2p_id"`
|
|
} `json:"vendor_params"`
|
|
} `json:"vendor"`
|
|
PublicKey string `json:"public_key"`
|
|
Sign string `json:"sign"`
|
|
}
|
|
if err = json.Unmarshal(res, &v); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query.Set("client_public", hex.EncodeToString(clientPublic))
|
|
query.Set("client_private", hex.EncodeToString(clientPrivate))
|
|
query.Set("device_public", v.PublicKey)
|
|
query.Set("sign", v.Sign)
|
|
query.Set("vendor", getVendorName(v.Vendor.ID))
|
|
|
|
if v.Vendor.ID == 1 {
|
|
query.Set("uid", v.Vendor.Params.UID)
|
|
}
|
|
|
|
url.RawQuery = query.Encode()
|
|
return url.String(), nil
|
|
}
|
|
|
|
func getVendorName(i byte) string {
|
|
switch i {
|
|
case 1:
|
|
return "tutk"
|
|
case 3:
|
|
return "agora"
|
|
case 4:
|
|
return "cs2"
|
|
case 6:
|
|
return "mtp"
|
|
}
|
|
return fmt.Sprintf("%d", i)
|
|
}
|
|
|
|
func wakeUpCamera(url *url.URL) error {
|
|
const params = `{"id":1,"method":"wakeup","params":{"video":"1"}}`
|
|
did := url.Query().Get("did")
|
|
_, err := cloudUserRequest(url.User, "/home/rpc/"+did, params)
|
|
return err
|
|
}
|
|
|
|
func apiXiaomi(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case "GET":
|
|
apiDeviceList(w, r)
|
|
case "POST":
|
|
apiAuth(w, r)
|
|
}
|
|
}
|
|
|
|
func apiDeviceList(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
|
|
user := query.Get("id")
|
|
if user == "" {
|
|
cloudsMu.Lock()
|
|
users := make([]string, 0, len(tokens))
|
|
for s := range tokens {
|
|
users = append(users, s)
|
|
}
|
|
cloudsMu.Unlock()
|
|
|
|
api.ResponseJSON(w, users)
|
|
return
|
|
}
|
|
|
|
err := func() error {
|
|
region := query.Get("region")
|
|
res, err := cloudRequest(user, region, "/v2/home/device_list_page", "{}")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var v struct {
|
|
List []*Device `json:"list"`
|
|
}
|
|
|
|
log.Trace().Str("user", user).Msgf("[xiaomi] devices list: %s", res)
|
|
|
|
if err = json.Unmarshal(res, &v); err != nil {
|
|
return err
|
|
}
|
|
|
|
var items []*api.Source
|
|
|
|
for _, device := range v.List {
|
|
if !strings.Contains(device.Model, ".camera.") && !strings.Contains(device.Model, ".cateye.") {
|
|
continue
|
|
}
|
|
items = append(items, &api.Source{
|
|
Name: device.Name,
|
|
Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC),
|
|
URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s", user, region, device.IP, device.Did, device.Model),
|
|
})
|
|
}
|
|
|
|
api.ResponseSources(w, items)
|
|
return nil
|
|
}()
|
|
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type Device struct {
|
|
Did string `json:"did"`
|
|
Name string `json:"name"`
|
|
Model string `json:"model"`
|
|
MAC string `json:"mac"`
|
|
IP string `json:"localip"`
|
|
}
|
|
|
|
var auth *xiaomi.Cloud
|
|
|
|
func apiAuth(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
username := r.Form.Get("username")
|
|
password := r.Form.Get("password")
|
|
captcha := r.Form.Get("captcha")
|
|
verify := r.Form.Get("verify")
|
|
|
|
var err error
|
|
|
|
switch {
|
|
case username != "" || password != "":
|
|
auth = xiaomi.NewCloud(AppXiaomiHome)
|
|
err = auth.Login(username, password)
|
|
case captcha != "":
|
|
err = auth.LoginWithCaptcha(captcha)
|
|
case verify != "":
|
|
err = auth.LoginWithVerify(verify)
|
|
default:
|
|
http.Error(w, "wrong request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err == nil {
|
|
userID, token := auth.UserToken()
|
|
auth = nil
|
|
|
|
cloudsMu.Lock()
|
|
if tokens == nil {
|
|
tokens = map[string]string{userID: token}
|
|
} else {
|
|
tokens[userID] = token
|
|
}
|
|
cloudsMu.Unlock()
|
|
|
|
err = app.PatchConfig([]string{"xiaomi", userID}, token)
|
|
}
|
|
|
|
if err != nil {
|
|
var login *xiaomi.LoginError
|
|
if errors.As(err, &login) {
|
|
w.Header().Set("Content-Type", api.MimeJSON)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(err)
|
|
return
|
|
}
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
const AppXiaomiHome = "xiaomiio"
|
|
|
|
func GetBaseURL(region string) string {
|
|
switch region {
|
|
case "de", "i2", "ru", "sg", "us":
|
|
return "https://" + region + ".api.io.mi.com/app"
|
|
}
|
|
return "https://api.io.mi.com/app"
|
|
}
|