Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: <enter your code here>
```

Your auth token will be written into `scaleconnect.json`

**Example.** Get the data of all users from specific scales and region:

- `de` (Europe)
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 1 addition & 3 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
8 changes: 7 additions & 1 deletion docker/run.sh
Original file line number Diff line number Diff line change
@@ -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}
132 changes: 126 additions & 6 deletions internal/accounts.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package internal

import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/AlexxIT/SmartScaleConnect/pkg/core"
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
12 changes: 10 additions & 2 deletions internal/weights.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"slices"
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
Loading