Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v5
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

Syncs body composition from a Eufy smart scale to Garmin Connect and Strava.

> macOS only. Needs Python 3.12+ and a terminal.
> macOS and Windows. Needs Python 3.12+ and a terminal.

## Sync targets

Expand Down Expand Up @@ -48,6 +48,14 @@ source $HOME/.local/bin/env
uv tool install eufy-sync
```

**On Windows:** open PowerShell (press Start, type "powershell", hit Enter) and install uv the same way:

```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```

Open a fresh terminal so the `uv` command is found, then run `uv tool install eufy-sync`.

Then open a new terminal window, so it picks up the newly installed command, and run:

```bash
Expand Down Expand Up @@ -87,12 +95,14 @@ eufy-sync checks weekly and tells you when a new version is out. To update:
eufy-sync --update
```

## Automatic sync (macOS)
## Automatic sync

On first run you can opt into syncing every 4 hours. If you do, a macOS Launch Agent runs it in the background: weigh yourself, open your laptop later, and it syncs on its own. Logs go to `~/.garmin-sync/sync.log`, and you get a notification if something fails. Turn it off with `eufy-sync --uninstall-agent`.

If [terminal-notifier](https://github.com/julienXX/terminal-notifier) is installed (`brew install terminal-notifier`), clicking a failure notification opens Terminal with the fix command already running. Without it, notifications still appear; the click just does nothing useful.

On Windows the same option registers a Scheduled Task that runs every 4 hours with no visible window. When a run fails, a toast notification names the command to fix it.

## Headless Linux (server or VPS)

eufy-sync runs on Linux too, and a server is a good home for it: no laptop that has to be awake. Without a system keychain, credentials fall back to a file with `600` permissions.
Expand Down Expand Up @@ -150,7 +160,7 @@ On each run it pulls your Eufy history and checks a local SQLite DB for what eac

## Security

Passwords and OAuth tokens live in a single item in your macOS Keychain, not plaintext files. The config in `~/.garmin-sync/` holds only email addresses and Strava app credentials, at `600` permissions. Credentials go over HTTPS to Eufy, Garmin, and Strava only, and are never logged or sent anywhere else. The one other outbound call is a weekly version check to pypi.org, with no credentials. On a host without a keychain (headless Linux), or if you choose `--use-file-store`, credentials fall back to a single `600` file at `~/.garmin-sync/credentials.json`.
Passwords and OAuth tokens live in a single item in your macOS Keychain, not plaintext files. The config in `~/.garmin-sync/` holds only email addresses and Strava app credentials, at `600` permissions. Credentials go over HTTPS to Eufy, Garmin, and Strava only, and are never logged or sent anywhere else. The one other outbound call is a weekly version check to pypi.org, with no credentials. On a host without a keychain (headless Linux), or if you choose `--use-file-store`, credentials fall back to a single `600` file at `~/.garmin-sync/credentials.json`. On Windows the credential vault lives in Windows Credential Manager, and the file fallback relies on your user profile's permissions since Windows does not honor POSIX file modes.

### Credential storage

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.7.22"
__version__ = "1.8.0"

# Public API for programmatic use
from eufy_sync.garmin_auth import GarminAuth
Expand Down
45 changes: 30 additions & 15 deletions eufy_sync/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,21 @@
import sys
from pathlib import Path

from eufy_sync import platform_support
from eufy_sync.cli import doctor, maintenance, profiles, setup, shared, status, updater


def main() -> None:
try:
_main()
except KeyboardInterrupt:
# Ctrl+C at any prompt (MFA code, passwords, confirmations) should
# read as a cancel, not dump a traceback.
print("\nCancelled.")
sys.exit(130)


def _main() -> None:
import argparse

from eufy_sync import __version__
Expand All @@ -29,11 +40,11 @@ def main() -> None:
help="Show recent sync history, last N entries (default: 14)")
parser.add_argument("--backfill-days", type=int, default=None, help="Sync last N days")
parser.add_argument("--dry-run", action="store_true", help="Preview without uploading")
parser.add_argument("--headless", action="store_true", help="Never prompt; fail with a reauth message if login is needed (for Launch Agent)")
parser.add_argument("--headless", action="store_true", help="Never prompt; fail with a reauth message if login is needed (for scheduled runs)")
parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed sync logs")
parser.add_argument("--install-agent", action="store_true", help="Set up automatic sync (macOS Launch Agent)")
parser.add_argument("--uninstall-agent", action="store_true", help="Remove the automatic sync Launch Agent")
parser.add_argument("--uninstall", action="store_true", help="Remove all data, tokens, and Launch Agent")
parser.add_argument("--install-agent", action="store_true", help="Set up automatic sync every 4 hours")
parser.add_argument("--uninstall-agent", action="store_true", help="Remove automatic sync")
parser.add_argument("--uninstall", action="store_true", help="Remove all data, tokens, and automatic sync")
parser.add_argument("--use-file-store", action="store_true", help="Store credentials in a 0o600 file instead of the keychain (no keychain prompts)")
parser.add_argument("--use-keychain", action="store_true", help="Move credentials back into the system keychain")
parser.add_argument("--config", type=Path, default=None, help="Config path (default: ~/.garmin-sync/config.yaml)")
Expand All @@ -43,6 +54,12 @@ def main() -> None:
config_path = args.config or shared.DEFAULT_CONFIG
db_path = args.db or shared.DEFAULT_DB

# Configure logging before any command dispatch. Every path that can reach
# a Garmin login (setup, --update-password, --reauth, sync) needs the
# garminconnect logger quieted, or its per-strategy 429 warnings print
# through logging's last-resort handler and look like errors.
shared._configure_logging(args.verbose)

# Handle doctor - must report even on a fresh install, never launch the
# first-run wizard, and never traceback.
if args.doctor:
Expand Down Expand Up @@ -105,7 +122,6 @@ def main() -> None:

# Handle reauth
if args.reauth is not None:
shared._configure_logging(args.verbose)
target = None if args.reauth == "all" else args.reauth
maintenance._reauth(config_path, force=True, target=target)
return
Expand All @@ -123,7 +139,7 @@ def main() -> None:
if first_run and args.headless:
msg = "No config found. Run eufy-sync in a terminal to set up."
print(msg)
shared._notify("eufy-sync", msg)
platform_support.notify("eufy-sync", msg)
sys.exit(1)

try:
Expand All @@ -143,7 +159,7 @@ def main() -> None:
except Exception as e:
msg = f"eufy-sync could not start: {e}"
print(msg)
shared._notify("eufy-sync failed", str(e)[:200])
platform_support.notify("eufy-sync failed", str(e)[:200])
sys.exit(1)

has_garmin = any(u.garmin for u in config.users)
Expand Down Expand Up @@ -178,7 +194,6 @@ def main() -> None:
from eufy_sync.state import SyncState
from eufy_sync.eufy_client import AmbiguousProfileError

shared._configure_logging(args.verbose)
logger = logging.getLogger("eufy_sync")

updater._check_for_updates()
Expand All @@ -192,7 +207,7 @@ def main() -> None:
except Exception as e:
msg = f"eufy-sync could not start: {e}"
print(msg)
shared._notify("eufy-sync failed", str(e)[:200])
platform_support.notify("eufy-sync failed", str(e)[:200])
sys.exit(1)

try:
Expand Down Expand Up @@ -245,28 +260,28 @@ def main() -> None:
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)
if reauth_needed:
shared._notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth garmin", command="eufy-sync --reauth garmin")
platform_support.notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth garmin", command="eufy-sync --reauth garmin")
failure_notify.clear_network_failures()
elif eufy_password:
shared._notify("eufy-sync: Eufy login failed", "Run: eufy-sync --update-password", command="eufy-sync --update-password")
platform_support.notify("eufy-sync: Eufy login failed", "Run: eufy-sync --update-password", command="eufy-sync --update-password")
failure_notify.clear_network_failures()
elif multiple_profiles:
shared._notify("eufy-sync: choose your profile", "Run: eufy-sync --select-profile", command="eufy-sync --select-profile")
platform_support.notify("eufy-sync: choose your profile", "Run: eufy-sync --select-profile", command="eufy-sync --select-profile")
failure_notify.clear_network_failures()
elif all_transient and args.headless and not args.dry_run:
# A scheduled run that only hit network trouble. Stay quiet - the
# next run retries - unless several have failed in a row, which
# points at a real outage worth one heads-up.
count, hours = failure_notify.record_network_failure()
if failure_notify.should_escalate(count):
shared._notify(
platform_support.notify(
"eufy-sync: network still down",
f"No network for ~{round(hours)}h ({count} runs). "
"Measurements are waiting and will sync when it is back.",
)
else:
fail_msg = "; ".join(f"{name}: {err[:80]}" for name, err in failures)
shared._notify("eufy-sync failed", fail_msg)
platform_support.notify("eufy-sync failed", fail_msg)
failure_notify.clear_network_failures()
logger.error("Sync failed for: %s", "; ".join(f"{n}: {e[:80]}" for n, e in failures))
elif not args.dry_run:
Expand All @@ -285,7 +300,7 @@ def main() -> None:

if total > 0:
target_label = " and ".join(n.capitalize() for n in total_counts if total_counts[n] > 0)
shared._notify("eufy-sync", f"Synced {total} measurement{'s' if total != 1 else ''} to {target_label}")
platform_support.notify("eufy-sync", f"Synced {total} measurement{'s' if total != 1 else ''} to {target_label}")

if first_run:
if failures:
Expand Down
42 changes: 5 additions & 37 deletions eufy_sync/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,12 @@
"""
from __future__ import annotations

import platform
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path

from eufy_sync import credentials
from eufy_sync.cli import shared
from eufy_sync import platform_support
from eufy_sync.cli import updater
from eufy_sync.config import load_config
from eufy_sync.credentials import _keyring_available, active_store_label
Expand Down Expand Up @@ -94,9 +92,10 @@ def report(status: str, label: str, detail: str, fix: str | None = None) -> None
# 7. eufy cloud
_check_eufy_cloud(report, eufy_client)

# 8. launch agent (macOS only)
if platform.system() == "Darwin":
_check_launch_agent(report)
# 8. scheduled sync agent (where the platform manages one)
agent = platform_support.agent_status()
if agent is not None:
report(agent["status"], agent["label"], agent["detail"], agent["fix"])

# 9. state db
_check_state_db(report, db_path, user)
Expand Down Expand Up @@ -231,37 +230,6 @@ def _check_eufy_cloud(report, eufy_client) -> None:
pass


def _check_launch_agent(report) -> None:
try:
if not shared.LAUNCH_AGENT_PATH.exists():
report("WARN", "launch agent", "not installed", "eufy-sync --install-agent")
return

content = shared.LAUNCH_AGENT_PATH.read_text()
wrapper_name = shared.LAUNCH_WRAPPER_NAME
if wrapper_name not in content:
report(
"WARN", "launch agent",
"outdated registration (re-announces on every update)",
"eufy-sync --install-agent",
)
return

result = subprocess.run(
["launchctl", "list"],
capture_output=True,
text=True,
timeout=5,
)
if shared.LAUNCH_AGENT_LABEL not in (result.stdout or ""):
report("WARN", "launch agent", "installed but not loaded", "eufy-sync --install-agent")
return

report("PASS", "launch agent", "loaded, runs every 4h")
except Exception as e:
report("WARN", "launch agent", f"could not check ({e})", "eufy-sync --install-agent")


def _check_state_db(report, db_path: Path, user) -> None:
try:
state = SyncState(db_path)
Expand Down
Loading
Loading