Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3dc4b57
Fall back to Eufy's newer login host when the original is unavailable
sturimcode Oct 2, 2026
4e3d9e3
Move to Strava's new API host on its published schedule
sturimcode Oct 2, 2026
92541f0
Cap the weight-only reach-back and skip weights Zwift cannot take
sturimcode Oct 2, 2026
0be1f19
Remove the leftover Zwift probe credential path from setup
sturimcode Oct 2, 2026
88947c2
Label the latest weigh-in truthfully and refresh the uv index on update
sturimcode Oct 2, 2026
9248548
Update the FitEncoder timezone note and document TZ for headless inst…
sturimcode Oct 2, 2026
2ce6f1e
Keep the old Strava host as a fallback with no end date
sturimcode Oct 2, 2026
a50e580
Keep Garmin sessions until a new login succeeds and lock credential c…
sturimcode Oct 2, 2026
e22b8f0
Make keychain vault saves crash-safe
sturimcode Oct 2, 2026
adca916
Merge branch 'worktree-agent-a4e47495765c27af4' into release/1.14.0
sturimcode Oct 2, 2026
45be46f
Merge branch 'worktree-agent-a5213f6457d4b1b0d' into release/1.14.0
sturimcode Oct 2, 2026
ff15e22
Merge branch 'worktree-agent-aa3b66bdc6882cad0' into release/1.14.0
sturimcode Oct 2, 2026
264c3ed
Merge branch 'worktree-agent-a7c2059ed0644a51d' into release/1.14.0
sturimcode Oct 2, 2026
1d542e4
Label weigh-in age accurately in doctor and docs; declare test tools …
sturimcode Oct 2, 2026
918eb68
Run vault-writing password migrations only under the sync lock
sturimcode Oct 2, 2026
0f0cdfb
Give each vault save its own chunk names and sweep every tracked left…
sturimcode Oct 2, 2026
c84322c
Allow one Garmin relogin per run even when it succeeds
sturimcode Oct 2, 2026
0a44274
Serialize credential writes with a vault lock and close the review gaps
sturimcode Oct 2, 2026
589a30d
Keep the vault lock until uninstall finishes and recheck racing reads
sturimcode Oct 2, 2026
14e0b73
Release 1.14.0
sturimcode Oct 2, 2026
32826f7
Don't assume inode numbers are never reused in the lock retry test
sturimcode Oct 2, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ If you want to add another target later, follow [Adding Strava](#adding-strava)

```bash
eufy-sync # sync new measurements to every configured target
eufy-sync --status # show the last sync and token health
eufy-sync --status # show the latest synced weigh-in and token health
eufy-sync --dry-run # preview a sync without uploading
eufy-sync --doctor # check the setup and print fixes
eufy-sync --history # show recent sync history
Expand Down
2 changes: 1 addition & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Running `eufy-sync` with no options syncs new measurements to every configured t
## Check and preview

```bash
eufy-sync --status # last sync and token health
eufy-sync --status # latest synced weigh-in and token health
eufy-sync --history # the last 14 sync-history entries
eufy-sync --history 30 # choose how many entries to show
eufy-sync --dry-run # preview without uploading
Expand Down
16 changes: 16 additions & 0 deletions docs/headless-linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ A user timer normally runs only while that user's systemd manager is active. If
sudo loginctl enable-linger "$USER"
```

## Set the timezone

eufy-sync uses the machine's timezone to decide which day a weigh-in belongs to when it checks Garmin for an existing entry, and to show dates in its summaries and notifications. Servers, NAS boxes, and containers often run on UTC, which can put an evening weigh-in on the next day. Set `TZ` to your local zone for the scheduled run.

For the systemd service, add this line under `[Service]`:

```ini
Environment=TZ=America/New_York
```

For Docker, pass it when starting the container:

```bash
docker run -e TZ=America/New_York ...
```

## Check a scheduled run

```bash
Expand Down
2 changes: 1 addition & 1 deletion eufy_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Sync Eufy smart scale body composition data to Garmin Connect and Strava."""

__version__ = "1.13.2"
__version__ = "1.14.0"

# Public API for programmatic use
from eufy_sync.eufy_client import EufyClient, EufyMeasurement
Expand Down
162 changes: 98 additions & 64 deletions eufy_sync/cli/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""The eufy-sync command line entry point and sync driver."""
from __future__ import annotations

import contextlib
import logging
import sys
from pathlib import Path
Expand Down Expand Up @@ -64,6 +65,20 @@ def _password_failure_title(failures: list) -> str:
return "eufy-sync: login failed"


@contextlib.contextmanager
def _credential_lock(command: str):
"""Hold the sync lock for a command that changes credentials, tokens, or
config, or exit with a retry hint when a sync or another such command has
it. A lock file that cannot be opened also refuses, unlike the sync path:
an unserialized credential write can strand a token a sync just rotated."""
from eufy_sync.cli import lock
with lock.single_instance(require_lock=True) as acquired:
if not acquired:
print(f"Another eufy-sync run is in progress. Retry {command} when it finishes.")
sys.exit(1)
yield


def _sync_with_network_retry(user, state, **kwargs):
"""Give scheduled network failures one delayed retry, keeping partial counts."""
from eufy_sync.network import is_transient_network_error
Expand Down Expand Up @@ -91,6 +106,29 @@ def _sync_with_network_retry(user, state, **kwargs):
sleep(NETWORK_RETRY_DELAY)


def _exit_could_not_start(error: Exception) -> None:
msg = f"eufy-sync could not start: {error}"
print(msg)
platform_support.notify("eufy-sync failed", str(error)[:200])
sys.exit(1)


def _load_config_or_exit(config_path: Path, migrate: bool = True):
from eufy_sync.config import load_config
try:
return load_config(config_path, migrate=migrate)
except SystemExit:
raise
except Exception as e:
_exit_could_not_start(e)


def _check_target(args, config) -> None:
if args.target and not any(getattr(u, args.target, None) for u in config.users):
print(f"Target '{args.target}' is not configured. Check your config.")
sys.exit(1)


def main() -> None:
try:
_main()
Expand Down Expand Up @@ -169,14 +207,22 @@ def _main() -> None:

# Handle full uninstall
if args.uninstall:
maintenance._uninstall(shared.DATA_DIR, config_path=config_path, db_path=db_path)
from eufy_sync.cli import lock
with _credential_lock("--uninstall"):
removed = maintenance._uninstall(shared.DATA_DIR, config_path=config_path, db_path=db_path)
if removed:
# POSIX: delete the sync lock file before releasing it.
lock.unlink_while_held()
if removed:
maintenance._remove_lock_files(shared.DATA_DIR)
return

# Handle credential store mode switches
if args.use_file_store:
from eufy_sync import credentials
try:
credentials.use_file_store()
with _credential_lock("--use-file-store"):
credentials.use_file_store()
except RuntimeError as e:
print(str(e))
sys.exit(1)
Expand All @@ -186,7 +232,8 @@ def _main() -> None:
if args.use_keychain:
from eufy_sync import credentials
try:
credentials.use_keychain_store()
with _credential_lock("--use-keychain"):
credentials.use_keychain_store()
except RuntimeError as e:
print(str(e))
sys.exit(1)
Expand All @@ -209,55 +256,43 @@ def _main() -> None:

# Handle self-update
if args.update:
updater._self_update()
# A reinstall mid-sync swaps the code under the running process.
with _credential_lock("--update"):
updater._self_update()
return

# Handle Strava setup
if args.setup_strava:
setup._setup_strava(config_path)
with _credential_lock("--setup-strava"):
setup._setup_strava(config_path)
return

if args.setup_zwift:
from eufy_sync.cli import lock
with lock.single_instance(require_lock=True) as acquired:
if not acquired:
print("Another eufy-sync run is in progress. Retry --setup-zwift when it finishes.")
sys.exit(1)
with _credential_lock("--setup-zwift"):
setup._setup_zwift(config_path)
return

if args.disconnect_zwift:
from eufy_sync.cli import lock
with lock.single_instance(require_lock=True) as acquired:
if not acquired:
print("Another eufy-sync run is in progress. Retry --disconnect-zwift when it finishes.")
sys.exit(1)
with _credential_lock("--disconnect-zwift"):
maintenance._disconnect_zwift(config_path)
return

# Handle profile selection
if args.select_profile:
profiles._select_profile(config_path)
with _credential_lock("--select-profile"):
profiles._select_profile(config_path)
return

# Handle password update
if args.update_password:
from eufy_sync.cli import lock
with lock.single_instance(require_lock=True) as acquired:
if not acquired:
print("Another eufy-sync run is in progress. Retry --update-password when it finishes.")
sys.exit(1)
with _credential_lock("--update-password"):
maintenance._update_password(config_path)
return

# Handle reauth
if args.reauth is not None:
target = None if args.reauth == "all" else args.reauth
from eufy_sync.cli import lock
with lock.single_instance(require_lock=True) as acquired:
if not acquired:
print("Another eufy-sync run is in progress. Retry --reauth when it finishes.")
sys.exit(1)
with _credential_lock("--reauth"):
maintenance._reauth(config_path, force=True, target=target)
return

Expand All @@ -277,55 +312,36 @@ def _main() -> None:
platform_support.notify("eufy-sync", msg)
sys.exit(1)

try:
if first_run:
setup._first_run_setup(config_path)
else:
# Migrate existing plaintext passwords to keychain (one-time)
setup._migrate_config_passwords(config_path)
# One-time upgrade notice for users coming from eufy-garmin-sync
setup._show_upgrade_notice()

# Load config (passwords resolved from keychain or YAML fallback)
from eufy_sync.config import load_config
config = load_config(config_path)
except SystemExit:
raise
except Exception as e:
msg = f"eufy-sync could not start: {e}"
print(msg)
platform_support.notify("eufy-sync failed", str(e)[:200])
sys.exit(1)

has_garmin = any(u.garmin for u in config.users)

if args.target and not any(getattr(u, args.target, None) for u in config.users):
print(f"Target '{args.target}' is not configured. Check your config.")
sys.exit(1)

# Handle status
if args.status:
if args.status or args.history is not None:
# Read-only and unlocked, so it must not write the vault: no config
# password migration, and legacy keychain items are read in place
# rather than moved. A sync holding the lock may be saving the vault
# right now, and a second writer could undo its token refresh.
config = _load_config_or_exit(config_path, migrate=False)
_check_target(args, config)
from eufy_sync.state import SyncState
try:
state = SyncState(db_path)
except Exception as e:
print(f"Could not read sync state: {e}")
sys.exit(1)
status._show_status(state, config.users)
if args.status:
status._show_status(state, config.users)
else:
status._show_history(state, config.users, limit=args.history)
state.close()
return

# Handle history
if args.history is not None:
from eufy_sync.state import SyncState
if first_run:
# Setup stores passwords and can log in to Garmin and Zwift. The
# lock is released before the first sync takes it again below.
try:
state = SyncState(db_path)
with _credential_lock("eufy-sync"):
setup._first_run_setup(config_path)
except SystemExit:
raise
except Exception as e:
print(f"Could not read sync state: {e}")
sys.exit(1)
status._show_history(state, config.users, limit=args.history)
state.close()
return
_exit_could_not_start(e)

# Run sync
from eufy_sync.cli import lock
Expand All @@ -344,6 +360,24 @@ def _main() -> None:
print("Another eufy-sync run is in progress; skipping.")
return

# The password migrations below write the credential vault, so they
# run only while this process holds the lock.
if not first_run:
try:
# Migrate existing plaintext passwords to keychain (one-time)
setup._migrate_config_passwords(config_path)
# One-time upgrade notice for users coming from eufy-garmin-sync
setup._show_upgrade_notice()
except SystemExit:
raise
except Exception as e:
_exit_could_not_start(e)
# Load config (passwords resolved from keychain or YAML fallback)
config = _load_config_or_exit(config_path)

has_garmin = any(u.garmin for u in config.users)
_check_target(args, config)

updater._check_for_updates()

backfill = args.backfill_days
Expand Down
8 changes: 5 additions & 3 deletions eufy_sync/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ def _check_keychain(report) -> None:
return
report("PASS", "keychain", active_store_label())
except Exception as e:
report("PASS", "keychain", f"file store (no keychain prompts) ({e})")
# A damaged or unreadable credentials file now raises here instead
# of quietly falling back to the keychain.
report("FAIL", "keychain", str(e))


def _check_eufy_token(report, user):
Expand Down Expand Up @@ -281,9 +283,9 @@ def _check_state_db(report, db_path: Path, user) -> None:
days = ago.days
hours = int(ago.total_seconds() / 3600)
if days > 0:
report("PASS", "state db", f"last sync {days}d ago")
report("PASS", "state db", f"latest weigh-in {days}d ago")
else:
report("PASS", "state db", f"last sync {hours}h ago")
report("PASS", "state db", f"latest weigh-in {hours}h ago")
except Exception as e:
report("FAIL", "state db", str(e))
finally:
Expand Down
Loading
Loading