From aca2d67554e2e2e964b75c91a984561d52d530e0 Mon Sep 17 00:00:00 2001 From: slfdstrctd Date: Wed, 28 Jan 2026 00:06:21 +0100 Subject: [PATCH 1/3] Add support sync to Mi Fitness --- internal/weights.go | 12 ++- pkg/xiaomi/client.go | 172 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/internal/weights.go b/internal/weights.go index 3c337cf..d386108 100644 --- a/internal/weights.go +++ b/internal/weights.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "os" "slices" @@ -118,7 +119,7 @@ func SetWeights(config string, src []*core.Weight) error { case "csv", "json": return writeFile(config, src) - case AccGarmin, AccZeppXiaomi: + case AccGarmin, AccZeppXiaomi, AccMiFitness: return appendAccount(config, src) case "json/latest": @@ -212,6 +213,8 @@ func appendAccount(config string, src []*core.Weight) error { return err } + log.Printf("[sync] Source has %d weights, destination has %d weights", len(src), len(dst)) + acc, err := GetAccount(strings.Fields(config)) if err != nil { return err @@ -220,6 +223,7 @@ func appendAccount(config string, src []*core.Weight) error { client := acc.(core.AccountWithAddWeights) var add []*core.Weight + var skipped, replaced int for _, s := range src { i := slices.IndexFunc(dst, func(d *core.Weight) bool { @@ -235,12 +239,14 @@ func appendAccount(config string, src []*core.Weight) error { } } else if !client.Equal(s, d) { // replace + replaced++ if err = client.DeleteWeight(d); err != nil { return err } add = append(add, s) } else { - // skip + // skip (duplicate) + skipped++ } } else { if s.Weight > 0 { @@ -251,6 +257,8 @@ func appendAccount(config string, src []*core.Weight) error { } } + log.Printf("[sync] Skipped %d duplicates, replacing %d, adding %d new", skipped, replaced, len(add)) + if len(add) == 0 { return nil } diff --git a/pkg/xiaomi/client.go b/pkg/xiaomi/client.go index c0d5c4c..2cb949c 100644 --- a/pkg/xiaomi/client.go +++ b/pkg/xiaomi/client.go @@ -19,6 +19,7 @@ type Client struct { userID int64 // for some requests ssecurity []byte // for encryption passToken string + region string // for regional API (de, ru, sg, us, i2) } func NewClient(app string) *Client { @@ -33,6 +34,9 @@ func (c *Client) GetAllWeights() ([]*core.Weight, error) { } func (c *Client) getAllWeights(region string) ([]*core.Weight, error) { + // Store region for use in AddWeights + c.region = region + var weights []*core.Weight ts := time.Now().Add(24 * time.Hour).Unix() @@ -577,6 +581,174 @@ func MiFitnessURL(region string) string { // return "" //} +// AddWeights implements core.AccountWithAddWeights interface +func (c *Client) AddWeights(weights []*core.Weight) error { + if len(weights) == 0 { + return nil + } + + zoneOffset := c.getZoneOffset() + baseURL := MiFitnessURL(c.region) + phoneID := fmt.Sprintf("ssc_%d", c.userID) + + for _, w := range weights { + value := weightToMiFitnessValue(w) + valueJSON, err := json.Marshal(value) + if err != nil { + return err + } + + sid := w.Source + if sid == "" { + sid = fmt.Sprintf("ssc.%d", w.Date.Unix()) + } + + params := map[string]any{ + "phone_id": phoneID, + "data_list": []any{ + map[string]any{ + "sid": sid, + "key": "weight", + "time": w.Date.Unix(), + "value": string(valueJSON), + "zone_offset": zoneOffset, + }, + }, + } + + paramsJSON, err := json.Marshal(params) + if err != nil { + return err + } + + if _, err = c.Request(baseURL, "/app/v1/data/up_fitness_data", string(paramsJSON), nil); err != nil { + return fmt.Errorf("mifitness: add weight failed: %w", err) + } + } + + return nil +} + +// getZoneOffset returns timezone offset in seconds, inferring from region if in UTC +func (c *Client) getZoneOffset() int { + _, offset := time.Now().Zone() + if offset != 0 || c.region == "" { + return offset + } + // Fallback for Docker (UTC) based on region + switch c.region { + case "de", "eu": + return 3600 // CET + case "ru": + return 10800 // MSK + case "sg": + return 28800 // SGT + case "us": + return -18000 // EST + } + return 0 +} + +// DeleteWeight implements core.AccountWithAddWeights interface +func (c *Client) DeleteWeight(weight *core.Weight) error { + sid := weight.Source + if sid == "" { + sid = fmt.Sprintf("ssc.%d", weight.Date.Unix()) + } + + // DeleteFitnessData format: phone_id + keys array + // FitnessDataKey requires: sid, key, time + params := map[string]any{ + "phone_id": fmt.Sprintf("ssc_%d", c.userID), + "keys": []any{ + map[string]any{ + "sid": sid, + "key": "weight", + "time": weight.Date.Unix(), + }, + }, + } + + paramsJSON, err := json.Marshal(params) + if err != nil { + return err + } + + _, err = c.Request(MiFitnessURL(c.region), "/app/v1/data/delete_fitness_data", string(paramsJSON), nil) + if err != nil { + return fmt.Errorf("mifitness: delete weight failed: %w", err) + } + + return nil +} + +// Equal implements core.AccountWithAddWeights interface +func (c *Client) Equal(w1, w2 *core.Weight) bool { + return equalFloat(w1.Weight, w2.Weight) && + equalFloat(w1.BMI, w2.BMI) && + equalFloat(w1.BodyFat, w2.BodyFat) && + equalFloat(w1.BodyWater, w2.BodyWater) && + equalFloat(w1.BoneMass, w2.BoneMass) && + equalFloat(w1.MuscleMass, w2.MuscleMass) && + w1.MetabolicAge == w2.MetabolicAge && + w1.VisceralFat == w2.VisceralFat && + w1.BasalMetabolism == w2.BasalMetabolism && + w1.BodyScore == w2.BodyScore && + w1.HeartRate == w2.HeartRate && + equalFloat(w1.SkeletalMuscleMass, w2.SkeletalMuscleMass) && + equalFloat(w1.ProteinMass, w2.ProteinMass) +} + + +// JSON structure for Mi Fitness data +type miFitnessValue struct { + Time int64 `json:"time"` + Weight float32 `json:"weight"` + BMI float32 `json:"bmi,omitempty"` + BodyFatRate float32 `json:"body_fat_rate,omitempty"` + MoistureRate float32 `json:"moisture_rate,omitempty"` + BoneMass float32 `json:"bone_mass,omitempty"` + BodyAge int `json:"body_age,omitempty"` + MuscleMass float32 `json:"muscle_mass,omitempty"` + ProteinMass float32 `json:"protein_mass,omitempty"` + VisceralFat float32 `json:"visceral_fat,omitempty"` + BasalMetabolism int `json:"basal_metabolism,omitempty"` + BodyScore int `json:"body_score,omitempty"` + BPM int `json:"bpm,omitempty"` + SkeletalMuscleMass float32 `json:"skeletal_muscle_mass,omitempty"` +} + +// Converts core.Weight to Mi Fitness JSON value format +func weightToMiFitnessValue(w *core.Weight) *miFitnessValue { + return &miFitnessValue{ + Time: w.Date.Unix(), + Weight: w.Weight, + BMI: w.BMI, + BodyFatRate: w.BodyFat, + MoistureRate: w.BodyWater, + BoneMass: w.BoneMass, + BodyAge: w.MetabolicAge, + MuscleMass: w.MuscleMass, + ProteinMass: w.ProteinMass, + VisceralFat: float32(w.VisceralFat), + BasalMetabolism: w.BasalMetabolism, + BodyScore: w.BodyScore, + BPM: w.HeartRate, + SkeletalMuscleMass: w.SkeletalMuscleMass, + } +} + +// equalFloat compares two float32 values with tolerance +func equalFloat(f1, f2 float32) bool { + if f1 == f2 { + return true + } + if f1 > f2 { + return f1-f2 < 0.1 + } + return f2-f1 < 0.1 +} + func readProxyResponse(data []byte) ([]byte, error) { var res1 struct { Resp string `json:"resp"` From 50995082ab805af9c0444feaf382f37a5b85fadb Mon Sep 17 00:00:00 2001 From: slfdstrctd Date: Wed, 28 Jan 2026 22:53:11 +0100 Subject: [PATCH 2/3] Add Xiaomi Home auth with token --- docker/Dockerfile | 4 +- docker/run.sh | 8 +- internal/accounts.go | 132 ++++++++++++++++- pkg/xiaomi/auth.go | 332 +++++++++++++++++++++++++++++++++++++++++-- pkg/xiaomi/client.go | 2 + 5 files changed, 458 insertions(+), 20 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 05dd1ea..58e18d1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -31,8 +31,6 @@ RUN chmod a+x /run.sh COPY --from=build /build/scaleconnect /usr/local/bin/ -ENTRYPOINT ["/sbin/tini", "--"] +ENTRYPOINT ["/sbin/tini", "--", "/run.sh"] VOLUME /config WORKDIR /config - -CMD ["/run.sh"] diff --git a/docker/run.sh b/docker/run.sh index 8d3c240..8cd707b 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -1,8 +1,14 @@ #!/bin/sh set -e +# If arguments are provided, pass them directly +if [ $# -gt 0 ]; then + exec scaleconnect "$@" +fi + +# Otherwise, use default behavior if [ -f "/data/options.json" ]; then SLEEP=$(jq --raw-output ".sleep" /data/options.json) fi -scaleconnect -i -r ${SLEEP:-24h} +exec scaleconnect -i -r ${SLEEP:-24h} diff --git a/internal/accounts.go b/internal/accounts.go index 1719fa2..7458261 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -1,7 +1,11 @@ package internal import ( + "bufio" "errors" + "fmt" + "os" + "strings" "time" "github.com/AlexxIT/SmartScaleConnect/pkg/core" @@ -26,7 +30,6 @@ var accounts map[string]core.Account var cacheTS time.Time func GetAccount(fields []string) (core.Account, error) { - // Clean accounts every 23 hours, because there is no logic for token expiration. if now := time.Now(); now.After(cacheTS) { accounts = map[string]core.Account{} cacheTS = now.Add(23 * time.Hour) @@ -67,21 +70,138 @@ func getAccount(fields []string, key string) (core.Account, error) { return nil, errors.New("unsupported type: " + fields[0]) } - if acc, ok := acc.(core.AccountWithToken); ok { + if accWithToken, ok := acc.(core.AccountWithToken); ok { if token := LoadToken(key); token != "" { - if err := acc.LoginWithToken(token); err == nil { + if err := accWithToken.LoginWithToken(token); err == nil { return acc, nil } } } if err := acc.Login(fields[1], fields[2]); err != nil { - return nil, err + return handleLoginError(err, acc, fields, key) } - if acc, ok := acc.(core.AccountWithToken); ok { - SaveToken(key, acc.Token()) + if accWithToken, ok := acc.(core.AccountWithToken); ok { + saveTokenForAccount(accWithToken, fields[0], key) } return acc, nil } + +func handleLoginError(err error, acc core.Account, fields []string, key string) (core.Account, error) { + loginErr, ok := err.(*xiaomi.LoginError) + if !ok { + return nil, err + } + + xiaomiClient, ok := acc.(*xiaomi.Client) + if !ok { + return nil, err + } + + accountType := fields[0] + + if len(loginErr.Captcha) > 0 { + if isInteractive() { + return handleCaptchaInteractive(xiaomiClient, loginErr, fields[1], fields[2], accountType, key) + } + return nil, fmt.Errorf("captcha required: run with -it flags for interactive mode") + } + + if loginErr.VerifyPhone != "" || loginErr.VerifyEmail != "" { + if isInteractive() { + return handle2FAInteractive(xiaomiClient, loginErr, accountType, key) + } + verifyTarget := loginErr.VerifyPhone + if verifyTarget == "" { + verifyTarget = loginErr.VerifyEmail + } + return nil, fmt.Errorf("2FA verification required: code sent to %s, run with -it flags for interactive mode", verifyTarget) + } + + return nil, err +} + +func saveTokenForAccount(acc core.AccountWithToken, accountType, key string) { + token := acc.Token() + SaveToken(key, token) + + if !isXiaomiAccount(accountType) { + return + } + + xiaomiAcc, ok := acc.(interface{ UserToken() (string, string) }) + if !ok { + return + } + + accountID, _ := xiaomiAcc.UserToken() + accountKey := AccXiaomi + ":" + accountID + SaveToken(accountKey, token) +} + +func isXiaomiAccount(accountType string) bool { + return accountType == AccXiaomi || accountType == AccXiaomiHome || accountType == AccMiFitness +} + +func isInteractive() bool { + fileInfo, err := os.Stdin.Stat() + if err != nil { + return false + } + mode := fileInfo.Mode() + return (mode&os.ModeCharDevice) != 0 || (mode&os.ModeNamedPipe) != 0 +} + +func handleCaptchaInteractive(client *xiaomi.Client, loginErr *xiaomi.LoginError, username, password, accountType, key string) (core.Account, error) { + fmt.Println("\nCaptcha required") + + if err := os.WriteFile("captcha.png", loginErr.Captcha, 0644); err == nil { + fmt.Println("Captcha image saved to captcha.png") + } + + fmt.Print("Enter captcha code: ") + reader := bufio.NewReader(os.Stdin) + captcha, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read captcha code: %w", err) + } + captcha = strings.TrimSpace(captcha) + + if err := client.LoginWithCaptcha(captcha); err != nil { + if loginErr2, ok := err.(*xiaomi.LoginError); ok { + if loginErr2.VerifyPhone != "" || loginErr2.VerifyEmail != "" { + return handle2FAInteractive(client, loginErr2, accountType, key) + } + } + return nil, fmt.Errorf("captcha verification failed: %w", err) + } + + saveTokenForAccount(client, accountType, key) + return client, nil +} + +func handle2FAInteractive(client *xiaomi.Client, loginErr *xiaomi.LoginError, accountType, key string) (core.Account, error) { + fmt.Println("\n2FA verification required") + if loginErr.VerifyPhone != "" { + fmt.Printf("Verification code sent to phone: %s\n", loginErr.VerifyPhone) + } else if loginErr.VerifyEmail != "" { + fmt.Printf("Verification code sent to email: %s\n", loginErr.VerifyEmail) + } + + fmt.Print("Enter verification code: ") + reader := bufio.NewReader(os.Stdin) + code, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read verification code: %w", err) + } + code = strings.TrimSpace(code) + + if err := client.LoginWithVerify(code); err != nil { + return nil, fmt.Errorf("2FA verification failed: %w", err) + } + + saveTokenForAccount(client, accountType, key) + return client, nil +} diff --git a/pkg/xiaomi/auth.go b/pkg/xiaomi/auth.go index 2018228..050c506 100644 --- a/pkg/xiaomi/auth.go +++ b/pkg/xiaomi/auth.go @@ -16,6 +16,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "strconv" "strings" "time" @@ -27,18 +28,104 @@ const ( AppMiFitness = "miothealth" ) +type LoginError struct { + Captcha []byte `json:"captcha,omitempty"` + VerifyPhone string `json:"verify_phone,omitempty"` + VerifyEmail string `json:"verify_email,omitempty"` +} + +func (l *LoginError) Error() string { + return "" +} + func (c *Client) Login(username, password string) error { - res1, err := c.serviceLogin() + res, err := c.client.Get("https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=" + c.sid) if err != nil { return err } - res2, err := c.serviceLogin2(res1, username, password) + var v1 struct { + Qs string `json:"qs"` + Sign string `json:"_sign"` + Sid string `json:"sid"` + Callback string `json:"callback"` + } + body, err := readLoginResponse(res) + if err != nil { + return err + } + if err = json.Unmarshal(body, &v1); err != nil { + return err + } + + hash := fmt.Sprintf("%X", md5.Sum([]byte(password))) + + form := url.Values{ + "_json": {"true"}, + "hash": {hash}, + "sid": {v1.Sid}, + "callback": {v1.Callback}, + "_sign": {v1.Sign}, + "qs": {v1.Qs}, + "user": {username}, + } + cookies := "deviceId=" + core.RandString(16, 62) + + if c.auth != nil && c.auth["captcha_code"] != "" { + form.Set("captCode", c.auth["captcha_code"]) + cookies += "; ick=" + c.auth["ick"] + } + + req, err := http.NewRequest("POST", "https://account.xiaomi.com/pass/serviceLoginAuth2", strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Cookie", cookies) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Ssecurity []byte `json:"ssecurity"` + PassToken string `json:"passToken"` + Location string `json:"location"` + + CaptchaURL string `json:"captchaURL"` + NotificationURL string `json:"notificationUrl"` + } + body, err = readLoginResponse(res) if err != nil { return err } + if err = json.Unmarshal(body, &v2); err != nil { + return err + } + + c.auth = map[string]string{ + "username": username, + "password": password, + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.NotificationURL != "" { + return c.authStart(v2.NotificationURL) + } - return c.serviceLogin3(res2.Location) + if v2.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + c.auth = nil + c.ssecurity = v2.Ssecurity + c.passToken = v2.PassToken + + return c.finishAuth(v2.Location) } type loginResponse1 struct { @@ -141,21 +228,242 @@ func (c *Client) serviceLogin2(res1 *loginResponse1, username, password string) return &res2, nil } -func (c *Client) serviceLogin3(location string) error { +func (c *Client) LoginWithCaptcha(captcha string) error { + if c.auth == nil || c.auth["ick"] == "" { + return errors.New("wrong login step: captcha not requested") + } + + c.auth["captcha_code"] = captcha + + if c.auth["flag"] != "" { + return c.sendTicket() + } + + return c.Login(c.auth["username"], c.auth["password"]) +} + +func (c *Client) LoginWithVerify(ticket string) error { + if c.auth == nil || c.auth["flag"] == "" { + return errors.New("wrong login step: verification not requested") + } + + form := url.Values{ + "_flag": {c.auth["flag"]}, + "ticket": {ticket}, + "trust": {"false"}, + "_json": {"true"}, + } + + req, err := http.NewRequest("POST", "https://account.xiaomi.com/identity/auth/verify"+c.verifyName(), strings.NewReader(form.Encode())) + if err != nil { + return err + } + + req.Header.Set("Cookie", "identity_session="+c.auth["identity_session"]) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Location string `json:"location"` + } + body, err := readLoginResponse(res) + if err != nil { + return err + } + if err = json.Unmarshal(body, &v1); err != nil { + return err + } + if v1.Location == "" { + return fmt.Errorf("xiaomi: %s", body) + } + + return c.finishAuth(v1.Location) +} + +func (c *Client) getCaptcha(captchaURL string) error { + res, err := c.client.Get("https://account.xiaomi.com" + captchaURL) + if err != nil { + return err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + c.auth["ick"] = findCookie(res, "ick") + + return &LoginError{ + Captcha: body, + } +} + +func findCookie(res *http.Response, name string) string { + for _, cookie := range res.Cookies() { + if cookie.Name == name { + return cookie.Value + } + } + return "" +} + +func (c *Client) authStart(notificationURL string) error { + rawURL := strings.Replace(notificationURL, "/fe/service/identity/authStart", "/identity/list", 1) + res, err := c.client.Get(rawURL) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + Flag int `json:"flag"` + } + body, err := readLoginResponse(res) + if err != nil { + return err + } + if err = json.Unmarshal(body, &v1); err != nil { + return err + } + + c.auth["flag"] = strconv.Itoa(v1.Flag) + c.auth["identity_session"] = findCookie(res, "identity_session") + + return c.sendTicket() +} + +func (c *Client) verifyName() string { + switch c.auth["flag"] { + case "4": + return "Phone" + case "8": + return "Email" + } + return "" +} + +func (c *Client) sendTicket() error { + name := c.verifyName() + cookies := "identity_session=" + c.auth["identity_session"] + + req, err := http.NewRequest("GET", "https://account.xiaomi.com/identity/auth/verify"+name, nil) + if err != nil { + return err + } + req.URL.RawQuery = "_flag=" + c.auth["flag"] + "&_json=true" + req.Header.Set("Cookie", cookies) + + res, err := c.client.Do(req) + if err != nil { + return err + } + + var v1 struct { + Code int `json:"code"` + MaskedPhone string `json:"maskedPhone"` + MaskedEmail string `json:"maskedEmail"` + } + body, err := readLoginResponse(res) + if err != nil { + return err + } + if err = json.Unmarshal(body, &v1); err != nil { + return err + } + + captCode := c.auth["captcha_code"] + if captCode != "" { + cookies += "; ick=" + c.auth["ick"] + } + + form := url.Values{ + "_json": {"true"}, + "icode": {captCode}, + "retry": {"0"}, + } + + req, err = http.NewRequest("POST", "https://account.xiaomi.com/identity/auth/send"+name+"Ticket", strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Cookie", cookies) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err = c.client.Do(req) + if err != nil { + return err + } + + var v2 struct { + Code int `json:"code"` + CaptchaURL string `json:"captchaURL"` + } + body, err = readLoginResponse(res) + if err != nil { + return err + } + if err = json.Unmarshal(body, &v2); err != nil { + return err + } + + if v2.CaptchaURL != "" { + return c.getCaptcha(v2.CaptchaURL) + } + + if v2.Code != 0 { + return fmt.Errorf("xiaomi: %s", body) + } + + return &LoginError{ + VerifyPhone: v1.MaskedPhone, + VerifyEmail: v1.MaskedEmail, + } +} + +func (c *Client) finishAuth(location string) error { res, err := c.client.Get(location) if err != nil { return err } defer res.Body.Close() - for _, s := range res.Header["Set-Cookie"] { - s, _, _ = strings.Cut(s, ";") - if len(c.cookies) > 0 { - c.cookies += "; " + var cUserId, serviceToken string + + for res != nil { + for _, cookie := range res.Cookies() { + switch cookie.Name { + case "userId": + userID, _ := strconv.ParseInt(cookie.Value, 10, 64) + c.userID = userID + case "cUserId": + cUserId = cookie.Value + case "serviceToken": + serviceToken = cookie.Value + case "passToken": + c.passToken = cookie.Value + } + } + + if s := res.Header.Get("Extension-Pragma"); s != "" { + var v1 struct { + Ssecurity []byte `json:"ssecurity"` + } + if err = json.Unmarshal([]byte(s), &v1); err != nil { + return err + } + c.ssecurity = v1.Ssecurity } - c.cookies += s + + res = res.Request.Response } + c.cookies = fmt.Sprintf("userId=%d; cUserId=%s; serviceToken=%s", c.userID, cUserId, serviceToken) + return nil } @@ -339,13 +647,17 @@ func (c *Client) LoginWithToken(token string) error { c.ssecurity = res2.Ssecurity c.userID = res2.UserId - return c.serviceLogin3(res2.Location) + return c.finishAuth(res2.Location) } func (c *Client) Token() string { return fmt.Sprintf("%d:%s", c.userID, c.passToken) } +func (c *Client) UserToken() (string, string) { + return fmt.Sprintf("%d", c.userID), c.passToken +} + const loginPrefix = "&&&START&&&" func readLoginResponse(res *http.Response) ([]byte, error) { diff --git a/pkg/xiaomi/client.go b/pkg/xiaomi/client.go index 2cb949c..bdef3c5 100644 --- a/pkg/xiaomi/client.go +++ b/pkg/xiaomi/client.go @@ -20,6 +20,8 @@ type Client struct { ssecurity []byte // for encryption passToken string region string // for regional API (de, ru, sg, us, i2) + + auth map[string]string // for multi-step auth (captcha, 2FA) } func NewClient(app string) *Client { From 503a23707b60dc79894a4e6b89849c4f8c5be491 Mon Sep 17 00:00:00 2001 From: slfdstrctd Date: Wed, 28 Jan 2026 23:22:24 +0100 Subject: [PATCH 3/3] Update docs --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/README.md b/README.md index 0583789..2107fea 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Application for synchronizing smart scale data between different ecosystems. * [From: Xiaomi Home](#from-xiaomi-home) * [From: Zepp Life](#from-zepp-life) * [To: Zepp Life](#to-zepp-life) + * [To: Mi Fitness](#to-mi-fitness) * [From: My TANINA](#from-my-tanina) * [From: Picooc](#from-picooc) * [From: Fitbit](#from-fitbit) @@ -208,6 +209,36 @@ Tested on scales: - **Mi Body Composition Scale S400 EU** (`MJTZC01YM`, `yunmai.scales.ms104`) - getting other users data is supported. +**Authentication.** When authentication requires two-factor verification, you must run the application **interactively** and **provide config file in parameters** (once, to obtain an auth token) so it can prompt you for the verification code. + +**With Docker:** +``` +docker run --rm -it -v "$(pwd)":/config scaleconnect -c scaleconnect.yaml +``` + +**Without Docker:** + +``` +go run . -c scaleconnect.yaml +``` + +Or build a binary and run it: + +``` +go build -o scaleconnect . +./scaleconnect -c scaleconnect.yaml +``` + +Then it will ask for code: + +``` +2FA verification required +Verification code sent to email: ***@email.com +Enter verification code: +``` + +Your auth token will be written into `scaleconnect.json` + **Example.** Get the data of all users from specific scales and region: - `de` (Europe) @@ -277,6 +308,29 @@ sync_zepp: to: zepp/xiaomi {username} {password} ``` +### To: Mi Fitness + +You can upload data to [Mi Fitness]. + +**Example.** Upload data to Mi Fitness from CSV (China region): + +```yaml +sync_mifitness: + from: csv alex_data.csv + to: mifitness {username} {password} +``` + +**Example.** Upload data to Mi Fitness from CSV (other region): + +```yaml +sync_mifitness: + from: csv alex_data.csv + to: mifitness {username} {password} {region} +``` + +Supported regions: `de` (Europe), `i2` (India), `ru` (Russia), `sg` (Singapore), `us` (United States). + + ### From: My TANINA On Tanita servers, the weighing time is stored with an unknown time zone and may be incorrect.