package xiaomi import ( "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "sync" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/xiaomi" "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" "github.com/eduard256/strix/internal/api" "github.com/eduard256/strix/internal/app" "github.com/eduard256/strix/pkg/generate" "github.com/eduard256/strix/pkg/tester" "github.com/rs/zerolog" ) func Init() { log = app.GetLogger("xiaomi") tester.RegisterSource("xiaomi", func(rawURL string) (core.Producer, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err } // stateless: token comes from URL query, not from persistent config if token := u.Query().Get("token"); token != "" && u.User != nil { userID := u.User.Username() cloudsMu.Lock() if tokens == nil { tokens = map[string]string{userID: token} } else { tokens[userID] = token } cloudsMu.Unlock() } 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) generate.RegisterExtract("xiaomi", extractForConfig) } // extractForConfig strips ?token=... from xiaomi:// URL and returns // userID + token for a top-level `xiaomi:` yaml section. // ex. xiaomi://4161148305:cn@10.0.20.229?did=450924912&model=X&token=V1:... // -> xiaomi://4161148305:cn@10.0.20.229?did=450924912&model=X, "xiaomi", "4161148305", "V1:..." func extractForConfig(rawURL string) (cleaned, section, key, value string) { u, err := url.Parse(rawURL) if err != nil || u.User == nil { return rawURL, "", "", "" } q := u.Query() token := q.Get("token") if token == "" { return rawURL, "", "", "" } q.Del("token") u.RawQuery = q.Encode() return u.String(), "xiaomi", u.User.Username(), token } 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 } cloudsMu.Lock() token := tokens[user] cloudsMu.Unlock() type source struct { Name string `json:"name"` Info string `json:"info"` URL string `json:"url"` } var items []*source for _, device := range v.List { if !device.HasCamera() { continue } items = append(items, &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&token=%s", user, region, device.IP, device.Did, device.Model, url.QueryEscape(token)), }) } api.ResponseJSON(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"` } func (d *Device) HasCamera() bool { return strings.Contains(d.Model, ".camera.") || strings.Contains(d.Model, ".cateye.") || strings.Contains(d.Model, ".feeder.") } 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() } if err != nil { var login *xiaomi.LoginError if errors.As(err, &login) { w.Header().Set("Content-Type", "application/json") 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" }