Skip to content
Closed
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
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
![Python](https://img.shields.io/pypi/pyversions/eufy-sync)
![License](https://img.shields.io/badge/license-MIT-green)

Syncs body composition from a Eufy smart scale to Garmin Connect, plus current weight to Strava and Zwift.
Syncs body composition from a Eufy smart scale to Garmin Connect, daily weight and body fat to Intervals.icu, and current weight to Strava and Zwift.

> macOS, Windows, and headless Linux. Needs Python 3.12+ and a terminal.

Expand All @@ -17,10 +17,11 @@ Syncs body composition from a Eufy smart scale to Garmin Connect, plus current w
| Garmin Connect | Full body composition: weight, body fat, muscle mass, bone mass, hydration, BMR, visceral fat, metabolic age |
| Strava | Current weight |
| Zwift (experimental, opt-in) | Current weight, verified after saving |
| Intervals.icu | Weight and body fat on each day's wellness record |

## Install

You need a Eufy scale with cloud sync and an account with at least one target: Garmin Connect, Strava, or Zwift.
You need a Eufy scale with cloud sync and an account with at least one target: Garmin Connect, Strava, Zwift, or Intervals.icu.

Each installer block below can be pasted in whole; its lines run one after another.

Expand Down Expand Up @@ -66,9 +67,9 @@ Open a new terminal window so it picks up the newly installed command, then run:
eufy-sync
```

Setup asks for your Eufy login, then lets you choose Garmin Connect, Strava, and/or Zwift. Pick any combination, including Zwift alone. It asks for the credentials each selected service needs, lets you choose your profile on a shared Eufy account, and runs the first sync. Zwift is opt-in and marked experimental.
Setup asks for your Eufy login, then lets you choose Garmin Connect, Strava, Zwift, and/or Intervals.icu. Pick any combination, including a single target. It asks for the credentials each selected service needs, lets you choose your profile on a shared Eufy account, and runs the first sync. Zwift is opt-in and marked experimental.

If you want to add another target later, follow [Adding Strava](#adding-strava) or [Adding Zwift](#adding-zwift-experimental).
If you want to add another target later, follow [Adding Strava](#adding-strava), [Adding Intervals.icu](#adding-intervalsicu), or [Adding Zwift](#adding-zwift-experimental).

> **Cloned the repo?** Run install commands from outside the repo directory to avoid path conflicts, e.g. `cd /tmp && pipx install eufy-sync`.

Expand Down Expand Up @@ -106,13 +107,24 @@ Strava requires an active subscription to create a new API application. This is
3. Run `eufy-sync --setup-strava` and enter the Client ID and Secret.
4. Authorize eufy-sync in the browser when it opens.

## Adding Intervals.icu

1. Open <https://intervals.icu/settings> and find **Developer Settings** near the bottom. Generate an API key there if you don't have one.
2. Run `eufy-sync --setup-intervals` and paste the key.

Setup reads your athlete profile with the key before saving anything, so a mistyped key changes nothing. It looks up your athlete ID on its own. The key goes into the credential store; `config.yaml` holds only the athlete ID.

Intervals.icu keeps one wellness record per day. Each sync sets that day's weight, plus body fat when the scale measured it. If you weigh in twice in one day, the later weigh-in wins, whichever order Eufy returns them in. A day is sent again only when its values change. The day is your computer's local date when eufy-sync first sees the weigh-in, the same date Garmin gets. A later timezone change does not move it. In the rare case that Eufy's raw and processed records of one weigh-in fall on either side of midnight, the weigh-in counts for the earlier day. Sleep, HRV, and the other wellness fields are left alone. Intervals.icu has no fields for muscle mass, bone mass, or the rest of Garmin's body composition, so those stay in Garmin.

The first sync looks back seven days. To fill in older days, run `eufy-sync --target intervals --backfill-days 30`. If Intervals.icu stops accepting the key, a notification asks you to run `eufy-sync --setup-intervals` again. `eufy-sync --disconnect-intervals` removes this target and its key.

## Adding Zwift (experimental)

To add Zwift to an existing installation, run `eufy-sync --setup-zwift` and enter your Zwift email and password. Setup checks that it can read your Zwift profile before enabling sync, then saves the login in the existing credential store. Later runs reuse the saved login and renew the session automatically.

Run `eufy-sync` to sync, or enable [automatic sync](#automatic-sync). Zwift receives only the newest valid weight for your selected Eufy profile, not body composition or weight history. Each update reads your current profile, changes its weight, then reads it again to verify the save. If the weight already matches, the tool records a verified match without sending another update.

This uses an unofficial Zwift route and is experimental. It has been verified on a real account, but that does not establish reliability across every account or future Zwift changes. Zwift failures are reported separately so Garmin and Strava can continue.
This uses an unofficial Zwift route and is experimental. It has been verified on a real account, but that does not establish reliability across every account or future Zwift changes. Zwift failures are reported separately so the other targets can continue.

Use `eufy-sync --target zwift --dry-run` to preview a run. The first sync looks back seven days; if your latest weigh-in is older, use `eufy-sync --target zwift --backfill-days 30`. Older backfill cannot replace a newer weight already recorded as synced. `eufy-sync --disconnect-zwift` removes this target and its saved login from the installation.

Expand All @@ -122,7 +134,7 @@ There is no telemetry. Credentials go over HTTPS only to the service they belong

Eufy can send a raw Wi-Fi weight to its cloud before the phone app processes the full body composition. If only weight syncs, open the Eufy app, wait for it to process the weigh-in, then run `eufy-sync` again.

Garmin, Eufy, and Zwift use unofficial APIs here; the Strava integration uses its official API. Any of these services can change. Headless login renewal is designed to recover automatically, but cannot be guaranteed to keep working after a service changes its login flow.
Garmin, Eufy, and Zwift use unofficial APIs here; Strava and Intervals.icu use their official APIs. Any of these services can change. Headless login renewal is designed to recover automatically, but cannot be guaranteed to keep working after a service changes its login flow.

## Tests

Expand Down
8 changes: 5 additions & 3 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ eufy-sync --version # show the installed version
eufy-sync --setup-strava # add or reconnect Strava
eufy-sync --setup-zwift # add experimental Zwift weight sync
eufy-sync --disconnect-zwift # remove Zwift and its saved login
eufy-sync --target garmin # sync one target (garmin, strava, or zwift)
eufy-sync --setup-intervals # add Intervals.icu, or replace its API key
eufy-sync --disconnect-intervals # remove Intervals.icu and its API key
eufy-sync --target garmin # sync one target (garmin, strava, zwift, or intervals)
eufy-sync --select-profile # choose a profile on a shared Eufy account
eufy-sync --reauth # log back into every configured target
eufy-sync --reauth garmin # log back into one target (garmin, strava, or zwift)
eufy-sync --update-password # change stored Eufy, Garmin, or Zwift passwords
```

On a fresh installation, run `eufy-sync` and choose any combination of Garmin, Strava, and experimental Zwift. The separate `--setup-zwift` command also supports a fresh Zwift-only installation, but does not offer automatic scheduling; run `eufy-sync --install-agent` afterward on macOS or Windows if needed.
On a fresh installation, run `eufy-sync` and choose any combination of Garmin, Strava, experimental Zwift, and Intervals.icu. `--setup-intervals` checks the key with a read before saving it; to change the key later, run it again. Intervals.icu has no `--reauth` or `--update-password` step: an API key has no login session to renew. The separate `--setup-zwift` command also supports a fresh Zwift-only installation, but does not offer automatic scheduling; run `eufy-sync --install-agent` afterward on macOS or Windows if needed.

## Automation and storage

Expand All @@ -52,7 +54,7 @@ eufy-sync --repair-days 30 # resend Garmin history even when recorded as del

`--repair-days` is for Garmin history that was deleted from Garmin Connect or filed under the wrong date by eufy-sync versions before 1.9.0. It resends Garmin measurements in the window even when the local database says they were delivered. It still leaves alone dates eufy-sync never uploaded when Garmin already holds data from another source.

Delete wrong-dated entries in Garmin Connect before repair. eufy-sync cannot delete them, so otherwise they remain beside the corrected entries. Strava and Zwift store current weight rather than history; in repair mode they receive only the newest eligible current weight.
Delete wrong-dated entries in Garmin Connect before repair. eufy-sync cannot delete them, so otherwise they remain beside the corrected entries. Strava and Zwift store current weight rather than history; in repair mode they receive only the newest eligible current weight. Intervals.icu keeps one record per day, so repair resends each day's newest weigh-in.

`--repair-days` and `--backfill-days` cannot be used together. Both can be limited to one target with `--target` and previewed with `--dry-run`.

Expand Down
2 changes: 1 addition & 1 deletion docs/headless-linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ source $HOME/.local/bin/env
uv tool install eufy-sync
```

Run `eufy-sync` once in an interactive SSH session. Setup lets you choose Garmin, Strava, and/or experimental Zwift. Garmin login and any two-factor code work in the terminal.
Run `eufy-sync` once in an interactive SSH session. Setup lets you choose Garmin, Strava, experimental Zwift, and/or Intervals.icu. Garmin login and any two-factor code work in the terminal.

For Strava, forward the authorization callback port when connecting from your computer. Replace `user@server` with your SSH login:

Expand Down
10 changes: 5 additions & 5 deletions docs/security-and-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ It checks configuration, credentials, target authorization, the local database,

## Credential storage

Credentials travel over HTTPS to their respective services: Eufy, Garmin, Strava, and Zwift. They are never logged or sent elsewhere. The only other outbound request is a weekly version check to `pypi.org`, without credentials.
Credentials travel over HTTPS to their respective services: Eufy, Garmin, Strava, Zwift, and Intervals.icu. They are never logged or sent elsewhere. The only other outbound request is a weekly version check to `pypi.org`, without credentials.

Passwords and OAuth tokens are stored in one place:

Expand All @@ -20,7 +20,7 @@ Passwords and OAuth tokens are stored in one place:

The keychain is used whenever it works. Systems without one fall back to the file automatically. `eufy-sync --use-file-store` moves existing credentials to the file and keeps using it; `eufy-sync --use-keychain` moves them back. A credentials file that was not adopted with `--use-file-store` does not override a working keychain. On Windows, file fallback relies on the user profile's permissions because Windows does not honor POSIX file modes.

`~/.garmin-sync/config.yaml` contains email addresses, the selected Eufy profile ID, and the public Strava client ID. It is written with `600` permissions on systems that support POSIX modes. Passwords and the Strava client secret remain in the credential store.
`~/.garmin-sync/config.yaml` contains email addresses, the selected Eufy profile ID, the public Strava client ID, and the Intervals.icu athlete ID. It is written with `600` permissions on systems that support POSIX modes. Passwords, the Strava client secret, and the Intervals.icu API key remain in the credential store.

## Garmin login recovery

Expand Down Expand Up @@ -81,9 +81,9 @@ The complete timer setup is in [Headless Linux](headless-linux.md).
Eufy Cloud -> eufy_client.py -> transform -> garmin_client.py -> Garmin (body comp)
(pull) (auth) (filter, -> strava_client.py -> Strava (weight)
dedup, -> zwift_client.py -> Zwift (weight)
state.db)
state.db) -> intervals_client.py -> Intervals.icu (weight, body fat per day)
```

Each run pulls Eufy history and checks the local SQLite database for what each target has already received. Garmin gets new full body-composition records through python-garminconnect's upload API. Dates Garmin already holds are skipped, which helps avoid duplicates when two machines sync the same account. Strava and Zwift receive the latest eligible current weight. Successful deliveries are recorded in the database; Zwift is recorded only after eufy-sync reads the profile again and verifies the saved weight.
Each run pulls Eufy history and checks the local SQLite database for what each target has already received. Garmin gets new full body-composition records through python-garminconnect's upload API. Dates Garmin already holds are skipped, which helps avoid duplicates when two machines sync the same account. Strava and Zwift receive the latest eligible current weight. Intervals.icu gets each day's newest weigh-in written to that day's wellness record, and a day is sent again only when those values change. The database keeps what was last sent to each day. Successful deliveries are recorded in the database; Zwift is recorded only after eufy-sync reads the profile again and verifies the saved weight.

Garmin, Eufy, and Zwift use unofficial APIs in this project. Strava uses its official API. Changes to any service can require an eufy-sync update.
Garmin, Eufy, and Zwift use unofficial APIs in this project. Strava and Intervals.icu use their official APIs. Changes to any service can require an eufy-sync update.
37 changes: 32 additions & 5 deletions eufy_sync/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from eufy_sync import platform_support
from eufy_sync.cli import doctor, maintenance, profiles, setup, shared, status, updater
from eufy_sync.reporting import SyncReport, garmin_existing_note, update_counts_summary
from eufy_sync.reporting import SyncReport, display_name, garmin_existing_note, update_counts_summary

logger = logging.getLogger("eufy_sync")
NETWORK_RETRY_DELAY = 60
Expand All @@ -18,7 +18,11 @@

def _target_label(total_counts: dict[str, int]) -> str:
"""Human label for the targets that received data, e.g. "Garmin and Strava"."""
names = [n.capitalize() for n in total_counts if total_counts[n] > 0]
return _join_names([display_name(n) for n in total_counts if total_counts[n] > 0])


def _join_names(names: list[str]) -> str:
""""A", "A and B", or "A, B and C"."""
if len(names) < 3:
return " and ".join(names)
return f"{', '.join(names[:-1])} and {names[-1]}"
Expand Down Expand Up @@ -146,7 +150,7 @@ def _main() -> None:

parser = argparse.ArgumentParser(
prog="eufy-sync",
description="Sync Eufy smart scale data to Garmin Connect, Strava, and Zwift",
description="Sync Eufy smart scale data to Garmin Connect, Strava, Zwift, and Intervals.icu",
)
parser.add_argument("--version", "-V", action="version", version=f"eufy-sync {__version__}")
parser.add_argument("--status", action="store_true", help="Show sync status and token health")
Expand All @@ -157,7 +161,9 @@ def _main() -> None:
parser.add_argument("--setup-strava", action="store_true", help="Connect Strava to your account")
parser.add_argument("--setup-zwift", action="store_true", help="Connect experimental Zwift weight sync")
parser.add_argument("--disconnect-zwift", action="store_true", help="Disconnect Zwift weight sync")
parser.add_argument("--target", choices=("garmin", "strava", "zwift"), default=None,
parser.add_argument("--setup-intervals", action="store_true", help="Connect Intervals.icu or replace its API key")
parser.add_argument("--disconnect-intervals", action="store_true", help="Disconnect Intervals.icu")
parser.add_argument("--target", choices=("garmin", "strava", "zwift", "intervals"), default=None,
help="Sync only one configured target")
parser.add_argument("--select-profile", action="store_true", help="Choose which Eufy profile to sync")
parser.add_argument("--update-password", action="store_true", help="Change stored passwords")
Expand Down Expand Up @@ -277,6 +283,16 @@ def _main() -> None:
maintenance._disconnect_zwift(config_path)
return

if args.setup_intervals:
with _credential_lock("--setup-intervals"):
setup._setup_intervals(config_path)
return

if args.disconnect_intervals:
with _credential_lock("--disconnect-intervals"):
maintenance._disconnect_intervals(config_path)
return

# Handle profile selection
if args.select_profile:
with _credential_lock("--select-profile"):
Expand Down Expand Up @@ -447,6 +463,7 @@ def _main() -> None:
"--update-password" in err or "changed your Eufy password" in err
for _, err in failures
)
intervals_setup_needed = any("--setup-intervals" in err for _, err in failures)
multiple_profiles = any("multiple Eufy profiles" in err for _, err in failures)
all_transient = all(failure_notify.is_transient_network_error(err) for _, err in failures)
completed = ""
Expand All @@ -469,6 +486,14 @@ def _main() -> None:
command="eufy-sync --update-password",
)
failure_notify.clear_network_failures()
elif intervals_setup_needed:
platform_support.notify(
"eufy-sync: Intervals.icu key rejected",
(completed.strip() + " " if completed else "")
+ "Run: eufy-sync --setup-intervals",
command="eufy-sync --setup-intervals",
)
failure_notify.clear_network_failures()
elif multiple_profiles:
platform_support.notify(
"eufy-sync: choose your profile",
Expand Down Expand Up @@ -531,7 +556,9 @@ def _main() -> None:
apps.append("Strava")
if any(u.zwift for u in config.users):
apps.append("Zwift")
print(f"You're all set! Check the {' and '.join(apps)} app to see your data.")
if any(u.intervals for u in config.users):
apps.append("Intervals.icu")
print(f"You're all set! Check the {_join_names(apps)} app to see your data.")
elif not args.verbose:
status._print_summary(total_counts, failures, state, config.users, report)

Expand Down
Loading
Loading