diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 630885d..b2e63ef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,11 +24,16 @@ jobs: run: | tag="${GITHUB_REF_NAME#v}" ver=$(grep -E '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/') + dunder=$(grep -E '^__version__ = ' eufy_sync/__init__.py | sed -E 's/__version__ = "(.*)"/\1/') if [ "$tag" != "$ver" ]; then echo "Tag $GITHUB_REF_NAME ($tag) does not match pyproject.toml version ($ver). Refusing to publish." exit 1 fi - echo "Tag and package version agree: $ver" + if [ "$tag" != "$dunder" ]; then + echo "Tag $GITHUB_REF_NAME ($tag) does not match eufy_sync/__init__.py __version__ ($dunder). Refusing to publish." + exit 1 + fi + echo "Tag and package versions agree: $ver" - run: pip install build - run: python -m build - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 6c37832..18d0c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ build/ CLAUDE.md .claude/ uv.lock +.superpowers/ diff --git a/.superpowers/sdd/revert-zwift-report.md b/.superpowers/sdd/revert-zwift-report.md deleted file mode 100644 index cb9ebd5..0000000 --- a/.superpowers/sdd/revert-zwift-report.md +++ /dev/null @@ -1,81 +0,0 @@ -# Zwift Revert Report - -## Reason - -Zwift silently ignores weight writes from API clients on accounts with a connected weight source (e.g. a paired Bluetooth scale or ANT+ device). The sync appeared to succeed but had no effect. Removed entirely. - -## Files removed - -- `eufy_sync/zwift_client.py` (git rm) -- `tests/test_zwift_client.py` (git rm) - -## Files modified - -### eufy_sync/config.py -- Removed `ZwiftConfig` dataclass -- Removed `zwift: ZwiftConfig | None = None` field from `UserConfig` -- Removed `zwift` parse block in `load_config` -- Removed `zwift=zwift` from `UserConfig(...)` construction -- Reverted "no sync targets" guard error message: no longer mentions zwift - -### eufy_sync/sync.py -- Reverted return type from `tuple[dict[str, int], dict[str, str]]` to `dict[str, int]` -- Removed `errors` dict and `return counts, errors` -> `return counts` -- Removed Zwift `if user.zwift:` try/except block -- Removed `if user.zwift and not state.has_any_syncs(user.name, "zwift"): new_target = True` line -- Updated docstring - -### eufy_sync/cli.py -- Reverted argparse `description` to "Sync Eufy smart scale data to Garmin Connect and Strava" -- Reverted `--reauth` help text (no longer mentions zwift) -- Removed `--setup-zwift` argument and dispatch block -- Removed `_setup_zwift` function -- Removed `do_zwift` variable and zwift reauth branch from `_reauth` -- Reverted both `sync_user` call sites from `counts, errors =` to `counts =`, removed `errors` loops and log args -- Removed zwift from `_update_password` (prompt, early-exit check, store/clear, "changed" list) -- Removed zwift sections from `_show_status` and `_print_summary` -- Removed `"zwift"` suffix from keychain loop in `_uninstall` and `delete_token("zwift")` -- Removed first-run wizard zwift prompt and related config-building -- Removed `zwift_password` parameter from `_store_passwords_in_keychain` -- Removed zwift from targets summary list in first-run - -### tests/test_sync.py -- Removed three zwift tests: `test_zwift_gets_exactly_one_put_per_sync`, `test_zwift_failure_does_not_block_strava`, `test_zwift_new_install_triggers_backfill` -- Removed `from eufy_sync.config import ZwiftConfig` -- Reverted `counts, errors = sync_user(...)` to `counts = sync_user(...)` and dropped `assert errors == {}` -- Added `GarminConfig` to import (was being imported inside removed test) - -### tests/test_config.py -- Removed `test_load_config_with_zwift_only` -- Removed `test_load_config_with_all_three_targets` - -### tests/test_cli.py -- Reverted `_uninstall` test: removed `"zwift": {"email": "z@example.com"}` from config and removed `assert "elias:zwift" in deleted_accounts` -- Reverted `fake_sync_user` that returned `(dict, dict)` to return just the dict - -### tests/test_summary.py -- Removed `has_zwift` parameter from `_mock_user` -- Removed `user.zwift` handling from `_mock_user` - -### README.md -- Reverted intro line (removed ", and Zwift") -- Removed Zwift row from sync targets table -- Removed Zwift-caveat paragraph after the table -- Reverted "How it works" diagram and prose (back to Garmin + Strava) -- Removed `--setup-zwift` usage line -- Reverted `--reauth` usage line -- Reverted disclaimer ("Eufy and Garmin" only, plus Strava) -- Reverted "Why" paragraph -- Reverted Security section - -## Final test count - -128 passed in 0.40s - -## grep -ri zwift eufy_sync/ tests/ README.md - -(no output - clean) - -## Uncertainties - -None. All changes were straightforward reversions. The `GarminConfig` import in `test_sync.py` was already used by other tests; the zwift tests had been importing it locally inside the test function. diff --git a/README.md b/README.md index 1ab82b7..5e3d060 100644 --- a/README.md +++ b/README.md @@ -138,13 +138,13 @@ If Garmin is already set up and you want Strava: ## How it works ``` -Eufy Cloud -> eufy_client.py -> transform -> garmin_client.py -> Garmin (FIT) +Eufy Cloud -> eufy_client.py -> transform -> garmin_client.py -> Garmin (body comp) (pull) (auth) (filter, -> strava_client.py -> Strava (weight) dedup, state.db) ``` -On each run it pulls your Eufy history and checks a local SQLite DB for what each target already has, then uploads only what is new: a FIT file to Garmin (skipping dates Garmin already holds, so two machines do not double up), and the latest weight to Strava. Every sync is recorded in the DB. +On each run it pulls your Eufy history and checks a local SQLite DB for what each target already has, then uploads only what is new: full body composition to Garmin through python-garminconnect's upload API (skipping dates Garmin already holds, so two machines do not double up), and the latest weight to Strava. Every sync is recorded in the DB. ## Security diff --git a/docs/superpowers/specs/2026-06-18-eufy-profile-selection-design.md b/docs/design/2026-06-18-eufy-profile-selection-design.md similarity index 98% rename from docs/superpowers/specs/2026-06-18-eufy-profile-selection-design.md rename to docs/design/2026-06-18-eufy-profile-selection-design.md index 768e2c5..41b1713 100644 --- a/docs/superpowers/specs/2026-06-18-eufy-profile-selection-design.md +++ b/docs/design/2026-06-18-eufy-profile-selection-design.md @@ -1,8 +1,6 @@ -# Eufy Profile Selection — Design +# Eufy Profile Selection - Design **Date:** 2026-06-18 -**Status:** approved, pending implementation plan -**Owner:** Elias **Fixes:** GitHub issue #1 (syncs every household member's weight) ## Goal diff --git a/docs/superpowers/specs/2026-06-18-garmin-auth-curl-cffi-design.md b/docs/design/2026-06-18-garmin-auth-curl-cffi-design.md similarity index 79% rename from docs/superpowers/specs/2026-06-18-garmin-auth-curl-cffi-design.md rename to docs/design/2026-06-18-garmin-auth-curl-cffi-design.md index 9044484..162303d 100644 --- a/docs/superpowers/specs/2026-06-18-garmin-auth-curl-cffi-design.md +++ b/docs/design/2026-06-18-garmin-auth-curl-cffi-design.md @@ -1,8 +1,6 @@ -# Garmin Auth Swap: Playwright to python-garminconnect — Design +# Garmin Auth Swap: Playwright to python-garminconnect - Design **Date:** 2026-06-18 -**Status:** approved, pending implementation plan -**Owner:** Elias ## Goal @@ -39,7 +37,7 @@ Out of scope: ### Deleted -- `eufy_sync/fit.py` and `tests/test_fit.py` — the library builds the FIT weigh-in file inside `add_body_composition`. +- `eufy_sync/fit.py` and `tests/test_fit.py` - the library builds the FIT weigh-in file inside `add_body_composition`. - The Playwright login, JS interception, DI token exchange/refresh, and `TokenPair`/`GarminSession` dataclasses in `garmin_auth.py`. Most of `tests/test_garmin_auth.py` is rewritten. ### `eufy_sync/garmin_auth.py` (rewritten) @@ -47,10 +45,10 @@ Out of scope: `GarminAuth` becomes a thin manager around `garminconnect.Garmin` plus token persistence. Public surface: -- `__init__(email, password, session_path=None)` — unchanged signature. -- `login(interactive: bool = True) -> Garmin` — returns an authenticated `garminconnect.Garmin`. Loads the saved token blob from the keychain and restores it with `client.loads(...)`; if there is no blob (or it does not load), does a fresh `Garmin(...).login()` when `interactive` is True, or raises `PermanentSyncError("Garmin login needed; run: eufy-sync --reauth")` when False. Persists the library's `dumps()` blob after a fresh login. -- `force_reauth() -> Garmin` — clears the stored token and does a fresh interactive login. -- `token_status() -> dict` — returns `{"state": "valid", "days_remaining": None}` when a token blob is present, else `{"state": "no_session", "days_remaining": None}`. (The library auto-refreshes, so the old refresh_needed/expired distinctions collapse; the shape stays compatible with the CLI formatters.) +- `__init__(email, password, session_path=None)` - unchanged signature. +- `login(interactive: bool = True) -> Garmin` - returns an authenticated `garminconnect.Garmin`. Loads the saved token blob from the keychain and restores it with `client.loads(...)`; if there is no blob (or it does not load), does a fresh `Garmin(...).login()` when `interactive` is True, or raises `PermanentSyncError("Garmin login needed; run: eufy-sync --reauth")` when False. Persists the library's `dumps()` blob after a fresh login. +- `force_reauth() -> Garmin` - clears the stored token and does a fresh interactive login. +- `token_status() -> dict` - returns `{"state": "valid", "days_remaining": None}` when a token blob is present, else `{"state": "no_session", "days_remaining": None}`. (The library auto-refreshes, so the old refresh_needed/expired distinctions collapse; the shape stays compatible with the CLI formatters.) Internal: - MFA callback: `lambda: input("Garmin MFA code (check your email): ").strip()`, passed as `prompt_mfa` to `Garmin(...)`. @@ -60,10 +58,10 @@ Internal: `GarminClient` holds a `GarminAuth` and an authenticated `garminconnect.Garmin`. No more raw httpx client. -- `authenticate(allow_interactive: bool = True) -> None` — `self._garmin = self._auth.login(interactive=allow_interactive)`. (Renamed from `allow_browser`; the one call site in `sync.py` updates.) -- `upload_body_composition(body_comp: GarminBodyComposition) -> dict` — calls `self._garmin.add_body_composition(timestamp=body_comp.timestamp, weight=body_comp.weight, percent_fat=..., percent_hydration=..., visceral_fat_rating=..., bone_mass=..., muscle_mass=..., basal_met=..., metabolic_age=..., bmi=...)`. Exact field set confirmed against `transform.py`'s `GarminBodyComposition` during implementation. -- `has_weight_on_date(dt: datetime) -> bool` — calls the library's body-composition/weigh-in read for that date and returns whether an entry exists. On a read error, returns False (same fail-open behavior as today). -- `close() -> None` — closes the library client if it holds a session; otherwise a no-op. +- `authenticate(allow_interactive: bool = True) -> None` - `self._garmin = self._auth.login(interactive=allow_interactive)`. (Renamed from `allow_browser`; the one call site in `sync.py` updates.) +- `upload_body_composition(body_comp: GarminBodyComposition) -> dict` - calls `self._garmin.add_body_composition(timestamp=body_comp.timestamp, weight=body_comp.weight, percent_fat=..., percent_hydration=..., visceral_fat_rating=..., bone_mass=..., muscle_mass=..., basal_met=..., metabolic_age=..., bmi=...)`. Exact field set confirmed against `transform.py`'s `GarminBodyComposition` during implementation. +- `has_weight_on_date(dt: datetime) -> bool` - calls the library's body-composition/weigh-in read for that date and returns whether an entry exists. On a read error, returns False (same fail-open behavior as today). +- `close() -> None` - closes the library client if it holds a session; otherwise a no-op. ### `eufy_sync/transform.py` (unchanged) diff --git a/docs/superpowers/specs/2026-06-20-garmin-hybrid-auth-design.md b/docs/design/2026-06-20-garmin-hybrid-auth-design.md similarity index 99% rename from docs/superpowers/specs/2026-06-20-garmin-hybrid-auth-design.md rename to docs/design/2026-06-20-garmin-hybrid-auth-design.md index ad9d051..929a09a 100644 --- a/docs/superpowers/specs/2026-06-20-garmin-hybrid-auth-design.md +++ b/docs/design/2026-06-20-garmin-hybrid-auth-design.md @@ -1,8 +1,6 @@ # Garmin Hybrid Auth: curl_cffi primary, browser fallback (Design) **Date:** 2026-06-20 -**Status:** approved, pending implementation plan -**Owner:** Elias ## Goal diff --git a/docs/superpowers/specs/2026-06-27-inline-profile-picker-and-quiet-429-design.md b/docs/design/2026-06-27-inline-profile-picker-and-quiet-429-design.md similarity index 99% rename from docs/superpowers/specs/2026-06-27-inline-profile-picker-and-quiet-429-design.md rename to docs/design/2026-06-27-inline-profile-picker-and-quiet-429-design.md index e797131..e0fb59c 100644 --- a/docs/superpowers/specs/2026-06-27-inline-profile-picker-and-quiet-429-design.md +++ b/docs/design/2026-06-27-inline-profile-picker-and-quiet-429-design.md @@ -1,7 +1,6 @@ # Inline Eufy profile picker and quieter Garmin login output Date: 2026-06-27 -Status: approved, ready for implementation plan ## Problem diff --git a/docs/superpowers/specs/2026-06-27-raw-wifi-weight-fallback-design.md b/docs/design/2026-06-27-raw-wifi-weight-fallback-design.md similarity index 99% rename from docs/superpowers/specs/2026-06-27-raw-wifi-weight-fallback-design.md rename to docs/design/2026-06-27-raw-wifi-weight-fallback-design.md index d08f81b..601a6b6 100644 --- a/docs/superpowers/specs/2026-06-27-raw-wifi-weight-fallback-design.md +++ b/docs/design/2026-06-27-raw-wifi-weight-fallback-design.md @@ -1,7 +1,6 @@ # Raw Wi-Fi weight fallback design Date: 2026-06-27 -Status: approved, ready for implementation plan ## Problem diff --git a/docs/superpowers/plans/2026-05-17-zwift-weight-sync.md b/docs/superpowers/plans/2026-05-17-zwift-weight-sync.md deleted file mode 100644 index a82a592..0000000 --- a/docs/superpowers/plans/2026-05-17-zwift-weight-sync.md +++ /dev/null @@ -1,1809 +0,0 @@ -# Zwift Weight Sync Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Zwift as a third sync target so a Eufy weight measurement updates the user's Zwift profile alongside Garmin Connect and Strava. - -**Architecture:** New `ZwiftClient` mirrors the existing Strava module (Keycloak password-grant + refresh-token auth, keychain-backed token storage). One `PUT /api/profiles/me` per sync uploads the newest measurement, with per-target failure isolation so a Zwift outage cannot block the other targets. `sync_user` returns `(counts, errors)` instead of just `counts` so the CLI summary can report per-target failures. - -**Tech Stack:** Python 3.12+, httpx, keyring, sqlite3, pytest. Same surface as the rest of `eufy-sync`. - -**Source spec:** `docs/superpowers/specs/2026-05-17-zwift-weight-sync-design.md` - ---- - -## File Structure - -**Create:** -- `eufy_sync/zwift_client.py` - `ZwiftClient` + token I/O helpers (parallel to `strava_client.py`) -- `tests/test_zwift_client.py` - unit tests for the new module - -**Modify:** -- `eufy_sync/config.py` - add `ZwiftConfig`, `UserConfig.zwift`, extend `load_config` and the "no sync targets" guard -- `eufy_sync/sync.py` - change `sync_user` return type from `dict[str, int]` to `tuple[dict[str, int], dict[str, str]]`, add Zwift block with per-target try/except -- `eufy_sync/cli.py` - handle new return shape, add wizard prompt, add `--setup-zwift`, extend `--reauth`, `--update-password`, `--status`, `_uninstall` -- `tests/test_sync.py` - three-target ordering test, Zwift isolation test, one-PUT-per-sync test -- `tests/test_config.py` - Zwift parsing test -- `tests/test_summary.py` - update for new return-shape consumers if needed -- `tests/test_cli.py` - `--setup-zwift` interactive test -- `README.md` - short "Sync targets" section noting Zwift is unofficial - ---- - -## Task 1: Refactor sync_user return shape - -Lock in the `(counts, errors)` tuple before adding the new target so we never have a half-migrated state. - -**Files:** -- Modify: `eufy_sync/sync.py` -- Modify: `eufy_sync/cli.py` -- Modify: `tests/test_sync.py` - -- [ ] **Step 1: Update the existing chronological-order test to expect the new return shape** - -In `tests/test_sync.py`, change `test_strava_receives_measurements_in_chronological_order` to unpack the tuple: - -```python -with patch("eufy_sync.sync.EufyClient", return_value=fake_eufy), \ - patch("eufy_sync.strava_client.StravaClient", return_value=fake_strava), \ - patch("eufy_sync.sync.time.sleep"): - counts, errors = sync_user(user, state, backfill_days=7) - -assert errors == {}, f"expected no errors, got {errors}" -``` - -- [ ] **Step 2: Run the test, confirm it fails** - -```bash -.venv/bin/python -m pytest tests/test_sync.py::test_strava_receives_measurements_in_chronological_order -xvs -``` - -Expected: `TypeError: cannot unpack non-iterable dict object` (or similar). - -- [ ] **Step 3: Change sync_user signature in `eufy_sync/sync.py`** - -In `sync.py`, change the function signature and final return: - -```python -def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = None, headless: bool = False, dry_run: bool = False) -> tuple[dict[str, int], dict[str, str]]: - """Sync one user's Eufy data to configured targets. - - Returns (counts, errors) where counts maps target name to # of synced - measurements, and errors maps target name to a failure message for - targets that failed. The dicts are disjoint - a successful target - appears only in counts, a failed target only in errors. - """ -``` - -At the bottom of the function, before the existing `return counts`, add an `errors` dict and return both: - -```python - errors: dict[str, str] = {} - return counts, errors -``` - -(The Zwift task will populate `errors`; for now it stays empty.) - -- [ ] **Step 4: Update cli.py to unpack the new shape** - -In `eufy_sync/cli.py`, the sync loop in `main()` calls `sync_user` in TWO places: the main attempt, and the retry inside the `except AmbiguousProfileError` block (the inline profile picker added the second one). Update BOTH. - -Main attempt currently reads: - -```python - counts = sync_user(user, state, backfill_days=backfill, headless=args.headless, dry_run=args.dry_run) - for target_name, count in counts.items(): - total_counts[target_name] = total_counts.get(target_name, 0) + count - logger.info("User %s: synced %s", user.name, counts) -``` - -Replace with: - -```python - counts, errors = sync_user(user, state, backfill_days=backfill, headless=args.headless, dry_run=args.dry_run) - for target_name, count in counts.items(): - total_counts[target_name] = total_counts.get(target_name, 0) + count - for target_name, err in errors.items(): - failures.append((f"{user.name}/{target_name}", err)) - logger.info("User %s: synced %s, errors %s", user.name, counts, errors) -``` - -The retry inside `except AmbiguousProfileError` currently reads: - -```python - counts = sync_user(user, state, backfill_days=backfill, headless=args.headless, dry_run=args.dry_run) - for target_name, count in counts.items(): - total_counts[target_name] = total_counts.get(target_name, 0) + count - logger.info("User %s: synced %s", user.name, counts) -``` - -Replace with: - -```python - counts, errors = sync_user(user, state, backfill_days=backfill, headless=args.headless, dry_run=args.dry_run) - for target_name, count in counts.items(): - total_counts[target_name] = total_counts.get(target_name, 0) + count - for target_name, err in errors.items(): - failures.append((f"{user.name}/{target_name}", err)) - logger.info("User %s: synced %s, errors %s", user.name, counts, errors) -``` - -- [ ] **Step 5: Run the full suite** - -```bash -.venv/bin/python -m pytest tests/ -x -``` - -Expected: all green. - -- [ ] **Step 6: Commit** - -```bash -git add eufy_sync/sync.py eufy_sync/cli.py tests/test_sync.py -git commit -m "refactor: sync_user returns (counts, errors) for per-target failure reporting" -``` - ---- - -## Task 2: Add ZwiftConfig to config.py - -**Files:** -- Modify: `eufy_sync/config.py` -- Create test in: `tests/test_config.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_config.py`: - -```python -@patch("eufy_sync.credentials._keyring_available", return_value=False) -def test_load_config_with_zwift_only(_keyring, tmp_path: Path): - path = tmp_path / "config.yaml" - _write(path, { - "users": [{ - "name": "default", - "eufy": {"email": "e@example.com", "password": "pw"}, - "zwift": {"email": "z@example.com", "password": "zwiftpw"}, - }], - }) - cfg = load_config(path) - assert cfg.users[0].zwift is not None - assert cfg.users[0].zwift.email == "z@example.com" - assert cfg.users[0].zwift.password == "zwiftpw" - assert cfg.users[0].garmin is None - assert cfg.users[0].strava is None - - -@patch("eufy_sync.credentials._keyring_available", return_value=False) -def test_load_config_with_all_three_targets(_keyring, tmp_path: Path): - path = tmp_path / "config.yaml" - _write(path, { - "users": [{ - "name": "default", - "eufy": {"email": "e@example.com", "password": "pw"}, - "garmin": {"email": "g@example.com", "password": "pw"}, - "strava": {"client_id": "12345", "client_secret": "ssec"}, - "zwift": {"email": "z@example.com", "password": "zwiftpw"}, - }], - }) - cfg = load_config(path) - assert cfg.users[0].garmin is not None - assert cfg.users[0].strava is not None - assert cfg.users[0].zwift is not None -``` - -- [ ] **Step 2: Run, confirm failure** - -```bash -.venv/bin/python -m pytest tests/test_config.py::test_load_config_with_zwift_only -xvs -``` - -Expected: `AttributeError: 'UserConfig' object has no attribute 'zwift'` (or `KeyError`). - -- [ ] **Step 3: Add ZwiftConfig dataclass and UserConfig.zwift field** - -In `eufy_sync/config.py`, after the `StravaConfig` dataclass, add: - -```python -@dataclass -class ZwiftConfig: - email: str - password: str -``` - -In the `UserConfig` dataclass, add the field: - -```python -@dataclass -class UserConfig: - name: str - eufy: EufyConfig - garmin: GarminConfig | None = None - strava: StravaConfig | None = None - zwift: ZwiftConfig | None = None -``` - -In `load_config`, after the Strava block and before the "no sync targets" guard, add: - -```python - zwift = None - if "zwift" in u: - zwift = ZwiftConfig( - email=u["zwift"]["email"], - password=_get_password(name, "zwift", u["zwift"]["email"], u["zwift"].get("password")), - ) - - if not garmin and not strava and not zwift: - raise ValueError( - f"User '{name}' has no sync targets configured. " - f"Add a 'garmin', 'strava', and/or 'zwift' section to your config." - ) -``` - -Delete the existing `if not garmin and not strava:` guard (replaced by the three-way guard above). - -Append `zwift=zwift,` to the `UserConfig(...)` call: - -```python - users.append(UserConfig( - name=name, - eufy=EufyConfig( - email=u["eufy"]["email"], - password=_get_password(name, "eufy", u["eufy"]["email"], u["eufy"].get("password")), - ), - garmin=garmin, - strava=strava, - zwift=zwift, - )) -``` - -- [ ] **Step 4: Run tests** - -```bash -.venv/bin/python -m pytest tests/test_config.py -x -``` - -Expected: all green. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/config.py tests/test_config.py -git commit -m "feat(config): add ZwiftConfig and UserConfig.zwift field" -``` - ---- - -## Task 3: ZwiftClient skeleton with token I/O helpers - -Token persistence + dataclass shape, no network yet. - -**Files:** -- Create: `eufy_sync/zwift_client.py` -- Create: `tests/test_zwift_client.py` - -- [ ] **Step 1: Write the failing test** - -Create `tests/test_zwift_client.py`: - -```python -from __future__ import annotations - -import time -from unittest.mock import patch - -from eufy_sync.config import ZwiftConfig -from eufy_sync.zwift_client import ZwiftClient, _load_tokens, _save_tokens - - -def _make_config(): - return ZwiftConfig(email="z@example.com", password="pw") - - -def _make_tokens(expired: bool = False): - return { - "access_token": "old" if expired else "valid", - "refresh_token": "rtok", - "expires_at": time.time() + (-3600 if expired else 3600), - } - - -def test_save_and_load_tokens_roundtrip_file_fallback(tmp_path, monkeypatch): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - tokens = _make_tokens() - _save_tokens(tokens) - loaded = _load_tokens() - assert loaded["access_token"] == "valid" - assert loaded["refresh_token"] == "rtok" - - -def test_load_tokens_returns_none_when_missing(tmp_path, monkeypatch): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - assert _load_tokens() is None -``` - -- [ ] **Step 2: Run, confirm fail** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py -xvs -``` - -Expected: `ImportError: cannot import name 'ZwiftClient'`. - -- [ ] **Step 3: Create the module** - -Write `eufy_sync/zwift_client.py`: - -```python -"""Zwift weight sync via reverse-engineered Keycloak OAuth2 password grant. - -Zwift does not publish a third-party API. This module talks to the same -endpoints the Companion app uses: -- Auth: POST https://secure.zwift.com/auth/realms/zwift/tokens/access/codes -- Profile write: PUT https://us-or-rly101.zwift.com/api/profiles/me - -The endpoints are community-reverse-engineered and may break with any -Zwift release. Failures here should not stop Garmin/Strava sync. -""" -from __future__ import annotations - -import json -import logging -import os -import time -from pathlib import Path - -import httpx - -from eufy_sync.config import ZwiftConfig - -logger = logging.getLogger(__name__) - -TOKEN_URL = "https://secure.zwift.com/auth/realms/zwift/tokens/access/codes" -PROFILE_URL = "https://us-or-rly101.zwift.com/api/profiles/me" -CLIENT_ID = "Zwift_Mobile_Link" -REFRESH_SAFETY_MARGIN = 300 # seconds before expiry to trigger refresh - - -def _keyring_available() -> bool: - from eufy_sync.credentials import _keyring_available as fn - return fn() - - -def _token_file_path() -> Path: - return Path.home() / ".garmin-sync" / "zwift_token.json" - - -def _load_tokens() -> dict | None: - """Load Zwift tokens from keychain or file fallback.""" - if _keyring_available(): - from eufy_sync.credentials import get_token - data = get_token("zwift") - if data: - return data - path = _token_file_path() - if path.exists(): - try: - return json.loads(path.read_text()) - except (json.JSONDecodeError, TypeError): - return None - return None - - -def _save_tokens(tokens: dict) -> None: - """Save Zwift tokens to keychain or file fallback.""" - if _keyring_available(): - from eufy_sync.credentials import store_token - store_token("zwift", tokens) - path = _token_file_path() - if path.exists(): - path.unlink() - return - - path = _token_file_path() - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w") as f: - f.write(json.dumps(tokens, indent=2)) - - -class ZwiftClient: - """Updates Zwift profile weight via reverse-engineered API. - - See module docstring for stability caveats. - """ - - def __init__(self, config: ZwiftConfig): - self.config = config - self._client = httpx.Client(timeout=30.0) - self._tokens: dict | None = None - - def close(self) -> None: - self._client.close() -``` - -- [ ] **Step 4: Run tests** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py -x -``` - -Expected: both tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/zwift_client.py tests/test_zwift_client.py -git commit -m "feat(zwift): add ZwiftClient skeleton and token I/O helpers" -``` - ---- - -## Task 4: ZwiftClient.authenticate with fresh password-grant login - -**Files:** -- Modify: `eufy_sync/zwift_client.py` -- Modify: `tests/test_zwift_client.py` - -- [ ] **Step 1: Write the failing test** - -Append to `tests/test_zwift_client.py`: - -```python -from unittest.mock import MagicMock - - -def test_authenticate_fresh_login_when_no_tokens(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "fresh_access", - "refresh_token": "fresh_refresh", - "expires_in": 3600, - } - - client = ZwiftClient(_make_config()) - with patch.object(client._client, "post", return_value=mock_response) as mock_post: - client.authenticate() - - # Called with password grant - call = mock_post.call_args - assert call.args[0] == "https://secure.zwift.com/auth/realms/zwift/tokens/access/codes" - assert call.kwargs["data"]["grant_type"] == "password" - assert call.kwargs["data"]["username"] == "z@example.com" - assert call.kwargs["data"]["password"] == "pw" - assert "Bearer fresh_access" in client._client.headers.get("Authorization", "") - client.close() -``` - -- [ ] **Step 2: Run, confirm fail** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py::test_authenticate_fresh_login_when_no_tokens -xvs -``` - -Expected: `AttributeError: 'ZwiftClient' object has no attribute 'authenticate'`. - -- [ ] **Step 3: Implement authenticate + _fresh_login** - -Append to `ZwiftClient` class in `eufy_sync/zwift_client.py`: - -```python - def authenticate(self) -> None: - """Load tokens from cache; refresh or fresh-login as needed.""" - self._tokens = _load_tokens() - - if self._tokens is None: - self._fresh_login() - elif time.time() >= (self._tokens["expires_at"] - REFRESH_SAFETY_MARGIN): - self._refresh_access_token() - - self._client.headers["Authorization"] = f"Bearer {self._tokens['access_token']}" - - def _fresh_login(self) -> None: - """Exchange email+password for tokens via Keycloak password grant.""" - now = time.time() - resp = self._client.post( - TOKEN_URL, - data={ - "client_id": CLIENT_ID, - "grant_type": "password", - "username": self.config.email, - "password": self.config.password, - }, - ) - if resp.status_code != 200: - logger.debug("Zwift login failed: %d %s", resp.status_code, resp.text) - from eufy_sync.sync import PermanentSyncError - raise PermanentSyncError( - f"Zwift login failed (HTTP {resp.status_code}). " - f"If you changed your Zwift password, run: eufy-sync --update-password" - ) - - data = resp.json() - self._tokens = { - "access_token": data["access_token"], - "refresh_token": data["refresh_token"], - "expires_at": now + data["expires_in"], - } - _save_tokens(self._tokens) - logger.info("Authenticated to Zwift as %s", self.config.email) -``` - -- [ ] **Step 4: Run tests** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py -x -``` - -Expected: all green. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/zwift_client.py tests/test_zwift_client.py -git commit -m "feat(zwift): add password-grant login flow" -``` - ---- - -## Task 5: Refresh-token flow when access token is expired - -**Files:** -- Modify: `eufy_sync/zwift_client.py` -- Modify: `tests/test_zwift_client.py` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_zwift_client.py`: - -```python -def test_authenticate_refreshes_expired_token(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - _save_tokens(_make_tokens(expired=True)) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "refreshed", - "refresh_token": "new_rtok", - "expires_in": 3600, - } - - client = ZwiftClient(_make_config()) - with patch.object(client._client, "post", return_value=mock_response) as mock_post: - client.authenticate() - - assert mock_post.call_args.kwargs["data"]["grant_type"] == "refresh_token" - assert mock_post.call_args.kwargs["data"]["refresh_token"] == "rtok" - assert "Bearer refreshed" in client._client.headers["Authorization"] - client.close() - - -def test_authenticate_falls_back_to_password_if_refresh_fails(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - _save_tokens(_make_tokens(expired=True)) - - refresh_fail = MagicMock(status_code=401, text="refresh dead") - fresh_ok = MagicMock(status_code=200) - fresh_ok.json.return_value = { - "access_token": "fresh_after_refresh_fail", - "refresh_token": "fresh_rtok", - "expires_in": 3600, - } - - client = ZwiftClient(_make_config()) - with patch.object(client._client, "post", side_effect=[refresh_fail, fresh_ok]): - client.authenticate() - assert "Bearer fresh_after_refresh_fail" in client._client.headers["Authorization"] - client.close() -``` - -- [ ] **Step 2: Run, confirm fail** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py::test_authenticate_refreshes_expired_token -xvs -``` - -Expected: `AttributeError: 'ZwiftClient' object has no attribute '_refresh_access_token'`. - -- [ ] **Step 3: Implement _refresh_access_token with password-grant fallback** - -Append to `ZwiftClient`: - -```python - def _refresh_access_token(self) -> None: - """Refresh the access token using the refresh token. Falls back to fresh login.""" - now = time.time() - resp = self._client.post( - TOKEN_URL, - data={ - "client_id": CLIENT_ID, - "grant_type": "refresh_token", - "refresh_token": self._tokens["refresh_token"], - }, - ) - if resp.status_code != 200: - logger.warning("Zwift refresh failed (%d), falling back to password login", resp.status_code) - self._tokens = None - self._fresh_login() - return - - data = resp.json() - self._tokens = { - "access_token": data["access_token"], - "refresh_token": data.get("refresh_token", self._tokens["refresh_token"]), - "expires_at": now + data["expires_in"], - } - _save_tokens(self._tokens) - logger.info("Refreshed Zwift access token") -``` - -- [ ] **Step 4: Run tests** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py -x -``` - -Expected: all green. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/zwift_client.py tests/test_zwift_client.py -git commit -m "feat(zwift): add refresh-token flow with password-grant fallback" -``` - ---- - -## Task 6: ZwiftClient.update_weight (the actual write) - -**Files:** -- Modify: `eufy_sync/zwift_client.py` -- Modify: `tests/test_zwift_client.py` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_zwift_client.py`: - -```python -import httpx -import pytest -from eufy_sync.sync import PermanentSyncError - - -def test_update_weight_sends_grams_not_kg(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - _save_tokens(_make_tokens()) - - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {"weight": 80000} - - client = ZwiftClient(_make_config()) - client.authenticate() - with patch.object(client._client, "put", return_value=mock_response) as mock_put: - client.update_weight(80.0) - - # Weight must be sent in grams, JSON body - payload = mock_put.call_args.kwargs["json"] - assert payload["weight"] == 80000 - assert mock_put.call_args.args[0] == "https://us-or-rly101.zwift.com/api/profiles/me" - client.close() - - -def test_update_weight_rounds_to_grams(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - _save_tokens(_make_tokens()) - - mock_response = MagicMock(status_code=200) - mock_response.json.return_value = {} - - client = ZwiftClient(_make_config()) - client.authenticate() - with patch.object(client._client, "put", return_value=mock_response) as mock_put: - client.update_weight(80.456) - - # 80.456 kg = 80456 g - assert mock_put.call_args.kwargs["json"]["weight"] == 80456 - client.close() - - -def test_update_weight_4xx_raises_permanent(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - _save_tokens(_make_tokens()) - - mock_response = MagicMock(status_code=401, text="unauth") - - client = ZwiftClient(_make_config()) - client.authenticate() - with patch.object(client._client, "put", return_value=mock_response): - with pytest.raises(PermanentSyncError): - client.update_weight(80.0) - client.close() - - -def test_update_weight_5xx_raises_retryable(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - _save_tokens(_make_tokens()) - - mock_response = MagicMock(status_code=503, text="busy") - - client = ZwiftClient(_make_config()) - client.authenticate() - with patch.object(client._client, "put", return_value=mock_response): - with pytest.raises(RuntimeError) as exc_info: - client.update_weight(80.0) - assert not isinstance(exc_info.value, PermanentSyncError) - client.close() -``` - -- [ ] **Step 2: Run, confirm fail** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py::test_update_weight_sends_grams_not_kg -xvs -``` - -Expected: `AttributeError: 'ZwiftClient' object has no attribute 'update_weight'`. - -- [ ] **Step 3: Implement update_weight** - -Append to `ZwiftClient`: - -```python - def update_weight(self, weight_kg: float) -> dict: - """Update Zwift profile weight. Sends grams. - - Returns the response JSON. 4xx raises PermanentSyncError so _retry - skips it; 5xx and network errors raise RuntimeError so _retry retries. - """ - weight_g = int(round(weight_kg * 1000)) - resp = self._client.put(PROFILE_URL, json={"weight": weight_g}) - - if resp.status_code != 200: - logger.debug("Zwift weight update failed: %d %s", resp.status_code, resp.text) - if 400 <= resp.status_code < 500 and resp.status_code != 429: - from eufy_sync.sync import PermanentSyncError - raise PermanentSyncError( - f"Failed to update Zwift weight (HTTP {resp.status_code})" - ) - raise RuntimeError( - f"Failed to update Zwift weight (HTTP {resp.status_code})" - ) - - logger.info("Updated Zwift weight to %.2f kg (%d g)", weight_kg, weight_g) - return resp.json() if resp.text else {"status": resp.status_code} -``` - -- [ ] **Step 4: Run tests** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py -x -``` - -Expected: all green. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/zwift_client.py tests/test_zwift_client.py -git commit -m "feat(zwift): add update_weight with permanent/retryable classification" -``` - ---- - -## Task 7: ZwiftClient.token_status - -Needed so the CLI status / summary code can report Zwift health alongside Garmin/Strava. - -**Files:** -- Modify: `eufy_sync/zwift_client.py` -- Modify: `tests/test_zwift_client.py` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_zwift_client.py`: - -```python -def test_token_status_no_session(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - client = ZwiftClient(_make_config()) - assert client.token_status()["state"] == "no_session" - client.close() - - -def test_token_status_valid(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - _save_tokens(_make_tokens()) - client = ZwiftClient(_make_config()) - status = client.token_status() - assert status["state"] == "valid" - client.close() - - -def test_token_status_refresh_needed(monkeypatch, tmp_path): - monkeypatch.setattr("eufy_sync.zwift_client._keyring_available", lambda: False) - monkeypatch.setenv("HOME", str(tmp_path)) - _save_tokens(_make_tokens(expired=True)) - client = ZwiftClient(_make_config()) - assert client.token_status()["state"] == "refresh_needed" - client.close() -``` - -- [ ] **Step 2: Run, confirm fail** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py::test_token_status_no_session -xvs -``` - -Expected: `AttributeError: 'ZwiftClient' object has no attribute 'token_status'`. - -- [ ] **Step 3: Implement token_status** - -Append to `ZwiftClient`: - -```python - def token_status(self) -> dict: - """Return token health matching the shape of StravaClient.token_status.""" - tokens = _load_tokens() - if tokens is None: - return {"state": "no_session", "days_remaining": None} - if "refresh_token" not in tokens or not tokens["refresh_token"]: - return {"state": "expired", "days_remaining": 0} - if time.time() >= (tokens["expires_at"] - REFRESH_SAFETY_MARGIN): - return {"state": "refresh_needed", "days_remaining": None} - hours = int((tokens["expires_at"] - time.time()) / 3600) - return {"state": "valid", "days_remaining": None, "hours_remaining": hours} -``` - -- [ ] **Step 4: Run tests** - -```bash -.venv/bin/python -m pytest tests/test_zwift_client.py -x -``` - -Expected: all green. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/zwift_client.py tests/test_zwift_client.py -git commit -m "feat(zwift): add token_status matching Strava/Garmin shape" -``` - ---- - -## Task 8: Wire Zwift into sync_user with failure isolation - -**Files:** -- Modify: `eufy_sync/sync.py` -- Modify: `tests/test_sync.py` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_sync.py`: - -```python -from eufy_sync.config import ZwiftConfig - - -def test_zwift_gets_exactly_one_put_per_sync(tmp_path: Path): - """Zwift's profile endpoint is heavy - we update once per sync, not once per measurement.""" - state = SyncState(tmp_path / "test.db") - - measurements = [ - _measurement(85.0, datetime(2026, 5, 10, 8, 0, tzinfo=timezone.utc)), - _measurement(85.5, datetime(2026, 5, 11, 8, 0, tzinfo=timezone.utc)), - _measurement(86.0, datetime(2026, 5, 12, 8, 0, tzinfo=timezone.utc)), - ] - - user = UserConfig( - name="default", - eufy=EufyConfig(email="e@example.com", password="pw"), - zwift=ZwiftConfig(email="z@example.com", password="zpw"), - ) - - fake_eufy = MagicMock() - fake_eufy.fetch_measurements.return_value = list(measurements) - - fake_zwift = MagicMock() - fake_zwift.update_weight.return_value = {"weight": 86000} - - with patch("eufy_sync.sync.EufyClient", return_value=fake_eufy), \ - patch("eufy_sync.zwift_client.ZwiftClient", return_value=fake_zwift), \ - patch("eufy_sync.sync.time.sleep"): - counts, errors = sync_user(user, state, backfill_days=7) - - assert fake_zwift.update_weight.call_count == 1, "Zwift should be PUT exactly once per sync" - assert fake_zwift.update_weight.call_args.args[0] == 86.0, "Should send the newest weight" - assert counts["zwift"] == 1 - assert errors == {} - # All three measurements must be marked synced for Zwift - for m in measurements: - assert state.is_synced("default", m.measurement_id, "zwift") - state.close() - - -def test_zwift_failure_does_not_block_strava(tmp_path: Path): - """A Zwift exception must not prevent Strava sync from completing.""" - state = SyncState(tmp_path / "test.db") - - measurement = _measurement(86.0, datetime(2026, 5, 12, 8, 0, tzinfo=timezone.utc)) - - user = UserConfig( - name="default", - eufy=EufyConfig(email="e@example.com", password="pw"), - strava=StravaConfig(client_id="cid", client_secret="csec"), - zwift=ZwiftConfig(email="z@example.com", password="zpw"), - ) - - fake_eufy = MagicMock() - fake_eufy.fetch_measurements.return_value = [measurement] - - fake_strava = MagicMock() - fake_strava.update_weight.return_value = {"weight": 86} - - fake_zwift = MagicMock() - fake_zwift.authenticate.side_effect = RuntimeError("Zwift exploded") - - with patch("eufy_sync.sync.EufyClient", return_value=fake_eufy), \ - patch("eufy_sync.strava_client.StravaClient", return_value=fake_strava), \ - patch("eufy_sync.zwift_client.ZwiftClient", return_value=fake_zwift), \ - patch("eufy_sync.sync.time.sleep"): - counts, errors = sync_user(user, state, backfill_days=7) - - assert counts.get("strava") == 1, "Strava must have succeeded" - assert "zwift" in errors, f"Zwift error must be reported; got {errors}" - assert "Zwift exploded" in errors["zwift"] - state.close() -``` - -- [ ] **Step 2: Run, confirm fail** - -```bash -.venv/bin/python -m pytest tests/test_sync.py::test_zwift_gets_exactly_one_put_per_sync -xvs -``` - -Expected: `AssertionError: assert 0 == 1` (no Zwift call made) or similar. - -- [ ] **Step 3: Add Zwift block to sync_user** - -In `eufy_sync/sync.py`, modify `sync_user`. The current return block at the end of the function looks like: - -```python - errors: dict[str, str] = {} - return counts, errors -``` - -Replace with a Zwift block immediately before the `return`: - -```python - errors: dict[str, str] = {} - - if user.zwift: - try: - from eufy_sync.zwift_client import ZwiftClient - zwift = ZwiftClient(user.zwift) - try: - zwift.authenticate() - unsynced = [ - m for m in measurements - if transform(m) is not None - and not state.is_synced(user.name, m.measurement_id, "zwift") - ] - if unsynced: - newest = unsynced[-1] # measurements sorted ascending earlier - if dry_run: - logger.info("[DRY RUN] Would sync to zwift: %.1f kg", newest.weight_kg) - counts["zwift"] = 1 - else: - result = _retry( - lambda: zwift.update_weight(newest.weight_kg), - f"Zwift update ({newest.measurement_id})", - ) - for m in unsynced: - state.record_sync( - user_name=user.name, - measurement_id=m.measurement_id, - measurement_timestamp=m.timestamp.isoformat(), - weight_kg=m.weight_kg, - synced_at=datetime.now(timezone.utc).isoformat(), - target="zwift", - response=json.dumps(result) if m is newest else None, - ) - counts["zwift"] = 1 - lb = newest.weight_kg * 2.20462 - logger.info( - "Synced %.2f kg (%.1f lb) -> Zwift (weight only)", - newest.weight_kg, lb, - ) - finally: - zwift.close() - except Exception as e: - logger.exception("Zwift sync failed; continuing") - errors["zwift"] = str(e) - - return counts, errors -``` - -- [ ] **Step 4: Run tests** - -```bash -.venv/bin/python -m pytest tests/ -x -``` - -Expected: all green. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/sync.py tests/test_sync.py -git commit -m "feat(sync): wire Zwift into sync_user with failure isolation" -``` - ---- - -## Task 9: First-run wizard prompt for Zwift - -**Files:** -- Modify: `eufy_sync/cli.py` - -- [ ] **Step 1: Add the Zwift block to _first_run_setup** - -In `eufy_sync/cli.py`, find the Strava block in `_first_run_setup`: - -```python - # Strava setup (optional) - print("") - strava_answer = input("Connect Strava? [y/N] ").strip() - strava_config = None - if strava_answer.lower().startswith("y"): - strava_config = _prompt_strava_credentials() -``` - -After it, add the Zwift block: - -```python - # Zwift setup (optional, unofficial) - print("") - zwift_answer = input("Connect Zwift? [y/N] ").strip() - zwift_email = None - zwift_password = None - if zwift_answer.lower().startswith("y"): - print("") - print(" Note: Zwift has no official API. eufy-sync uses a") - print(" community-reverse-engineered endpoint and may break with any Zwift update.") - print("") - zwift_email = input("Zwift email (Enter if same as Eufy): ").strip() - if not zwift_email: - zwift_email = eufy_email - zwift_password = getpass.getpass("Zwift password: ") - if not zwift_password: - print("Error: Zwift password is required.") - sys.exit(1) -``` - -Find the "at least one sync target" guard: - -```python - if not garmin_email and not strava_config: - print("Error: You must configure at least one sync target (Garmin or Strava).") - sys.exit(1) -``` - -Replace with: - -```python - if not garmin_email and not strava_config and not zwift_email: - print("Error: You must configure at least one sync target (Garmin, Strava, or Zwift).") - sys.exit(1) -``` - -Find the keychain-storage call: - -```python - keychain_ok = _store_passwords_in_keychain(user_name, eufy_password, garmin_password) -``` - -Change `_store_passwords_in_keychain` signature to accept zwift_password. Find the function definition: - -```python -def _store_passwords_in_keychain(user_name: str, eufy_password: str, garmin_password: str | None = None) -> bool: -``` - -Replace with: - -```python -def _store_passwords_in_keychain( - user_name: str, - eufy_password: str, - garmin_password: str | None = None, - zwift_password: str | None = None, -) -> bool: - """Store passwords in keychain. Returns True if successful.""" - from eufy_sync.credentials import store_password, _keyring_available - if not _keyring_available(): - return False - store_password(f"{user_name}:eufy", eufy_password) - if garmin_password: - store_password(f"{user_name}:garmin", garmin_password) - if zwift_password: - store_password(f"{user_name}:zwift", zwift_password) - return True -``` - -Update the call inside `_first_run_setup`: - -```python - keychain_ok = _store_passwords_in_keychain(user_name, eufy_password, garmin_password, zwift_password) -``` - -Find the config-building block: - -```python - user_config: dict = { - "name": user_name, - "eufy": {"email": eufy_email}, - } - if garmin_email: - user_config["garmin"] = {"email": garmin_email} - if strava_config: - user_config["strava"] = strava_config - if not keychain_ok: - # Fallback: store passwords in config file (with 0o600 permissions) - user_config["eufy"]["password"] = eufy_password - if garmin_password: - user_config["garmin"]["password"] = garmin_password - print("Warning: keychain not available, passwords stored in config file.") - else: - print("Passwords saved to system keychain.") -``` - -Add Zwift to both branches: - -```python - user_config: dict = { - "name": user_name, - "eufy": {"email": eufy_email}, - } - if garmin_email: - user_config["garmin"] = {"email": garmin_email} - if strava_config: - user_config["strava"] = strava_config - if zwift_email: - user_config["zwift"] = {"email": zwift_email} - if not keychain_ok: - # Fallback: store passwords in config file (with 0o600 permissions) - user_config["eufy"]["password"] = eufy_password - if garmin_password: - user_config["garmin"]["password"] = garmin_password - if zwift_password: - user_config["zwift"]["password"] = zwift_password - print("Warning: keychain not available, passwords stored in config file.") - else: - print("Passwords saved to system keychain.") -``` - -Update the "targets:" list: - -```python - targets = [] - if garmin_email: - targets.append("Garmin") - if strava_config: - targets.append("Strava") - if zwift_email: - targets.append("Zwift") - print(f"Saved. Running first sync to {' and '.join(targets)} (last 7 days)...") -``` - -- [ ] **Step 2: Run the suite** - -```bash -.venv/bin/python -m pytest tests/ -x -``` - -Expected: all green (no test changes needed for wizard prose). - -- [ ] **Step 3: Commit** - -```bash -git add eufy_sync/cli.py -git commit -m "feat(cli): add Zwift prompt to first-run wizard" -``` - ---- - -## Task 10: --setup-zwift flag - -**Files:** -- Modify: `eufy_sync/cli.py` - -- [ ] **Step 1: Add _setup_zwift function** - -In `eufy_sync/cli.py`, after `_setup_strava`, add: - -```python -def _setup_zwift(config_path: Path) -> None: - """Add or update Zwift configuration.""" - if not config_path.exists(): - print("No config found. Run eufy-sync first to set up.") - sys.exit(1) - - with open(config_path) as f: - config = yaml.safe_load(f) - - print("") - print(" Note: Zwift has no official API. eufy-sync uses a") - print(" community-reverse-engineered endpoint and may break with any Zwift update.") - print("") - - user = config["users"][0] - user_name = user.get("name", "default") - eufy_email = user["eufy"]["email"] - - zwift_email = input("Zwift email (Enter if same as Eufy): ").strip() or eufy_email - zwift_password = getpass.getpass("Zwift password: ") - if not zwift_password: - print("Error: Zwift password is required.") - sys.exit(1) - - from eufy_sync.credentials import store_password, _keyring_available - if _keyring_available(): - store_password(f"{user_name}:zwift", zwift_password) - user["zwift"] = {"email": zwift_email} - else: - user["zwift"] = {"email": zwift_email, "password": zwift_password} - - _write_config(config_path, config) - print("Zwift connected. Future syncs will update your Zwift profile weight.") -``` - -- [ ] **Step 2: Wire the flag into argparse and main dispatch** - -Find the argparse block in `main()`: - -```python - parser.add_argument("--setup-strava", action="store_true", help="Connect Strava to your account") -``` - -Add right after it: - -```python - parser.add_argument("--setup-zwift", action="store_true", help="Connect Zwift to your account") -``` - -Find the dispatch block: - -```python - # Handle Strava setup - if args.setup_strava: - _setup_strava(config_path) - return -``` - -Add right after it: - -```python - # Handle Zwift setup - if args.setup_zwift: - _setup_zwift(config_path) - return -``` - -- [ ] **Step 3: Run the suite** - -```bash -.venv/bin/python -m pytest tests/ -x -``` - -Expected: all green. - -- [ ] **Step 4: Commit** - -```bash -git add eufy_sync/cli.py -git commit -m "feat(cli): add --setup-zwift flag" -``` - ---- - -## Task 11: Extend --reauth to handle zwift target - -**Files:** -- Modify: `eufy_sync/cli.py` - -- [ ] **Step 1: Add do_zwift alongside do_garmin/do_strava and update the guard** - -In `eufy_sync/cli.py`, find this block in `_reauth`: - -```python - do_garmin = (target is None or target == "garmin") and "garmin" in user - do_strava = (target is None or target == "strava") and "strava" in user - - if target and not do_garmin and not do_strava: - print(f"Target '{target}' is not configured. Check your config.") - return -``` - -Replace with: - -```python - do_garmin = (target is None or target == "garmin") and "garmin" in user - do_strava = (target is None or target == "strava") and "strava" in user - do_zwift = (target is None or target == "zwift") and "zwift" in user - - if target and not do_garmin and not do_strava and not do_zwift: - print(f"Target '{target}' is not configured. Check your config.") - return -``` - -- [ ] **Step 2: Add the Zwift re-auth branch at the bottom of _reauth** - -After the existing Strava block: - -```python - if do_strava: - from eufy_sync.config import StravaConfig - from eufy_sync.strava_client import authorize_strava - strava_cfg = StravaConfig( - client_id=str(user["strava"]["client_id"]), - client_secret=user["strava"]["client_secret"], - ) - authorize_strava(strava_cfg) - print("Done - Strava tokens saved.") -``` - -Add: - -```python - if do_zwift: - from eufy_sync.config import ZwiftConfig, _get_password - from eufy_sync.credentials import _keyring_available, delete_token - from eufy_sync.zwift_client import ZwiftClient - zwift_email = user["zwift"]["email"] - zwift_pw = _get_password(user_name, "zwift", zwift_email, user["zwift"].get("password")) - # Force fresh password-grant login by clearing cached tokens - if _keyring_available(): - delete_token("zwift") - token_file = DATA_DIR / "zwift_token.json" - if token_file.exists(): - token_file.unlink() - client = ZwiftClient(ZwiftConfig(email=zwift_email, password=zwift_pw)) - client.authenticate() - client.close() - print("Done - Zwift tokens saved.") -``` - -- [ ] **Step 3: Run the suite** - -```bash -.venv/bin/python -m pytest tests/ -x -``` - -Expected: all green. - -- [ ] **Step 4: Commit** - -```bash -git add eufy_sync/cli.py -git commit -m "feat(cli): extend --reauth to handle zwift target" -``` - ---- - -## Task 12: Extend --update-password for Zwift - -**Files:** -- Modify: `eufy_sync/cli.py` - -- [ ] **Step 1: Update _update_password to prompt for Zwift** - -In `eufy_sync/cli.py`, find `_update_password`. After the Garmin prompt: - -```python - eufy_pw = getpass.getpass("New Eufy password: ") - garmin_pw = getpass.getpass("New Garmin password: ") -``` - -Change to include Zwift: - -```python - eufy_pw = getpass.getpass("New Eufy password: ") - garmin_pw = getpass.getpass("New Garmin password: ") if "garmin" in user else "" - zwift_pw = getpass.getpass("New Zwift password: ") if "zwift" in user else "" -``` - -Update the early-exit: - -```python - if not eufy_pw and not garmin_pw: - print("No changes made.") - return -``` - -To: - -```python - if not eufy_pw and not garmin_pw and not zwift_pw: - print("No changes made.") - return -``` - -After the Garmin block: - -```python - if garmin_pw: - if keychain_ok: - store_password(f"{user_name}:garmin", garmin_pw) - else: - user["garmin"]["password"] = garmin_pw -``` - -Add the Zwift block: - -```python - if zwift_pw: - if keychain_ok: - store_password(f"{user_name}:zwift", zwift_pw) - else: - user["zwift"]["password"] = zwift_pw -``` - -After the Garmin token-clearing block: - -```python - if garmin_pw: - if keychain_ok: - delete_token("garmin") - garmin_session = DATA_DIR / "session.json" - if garmin_session.exists(): - garmin_session.unlink() -``` - -Add the Zwift block: - -```python - if zwift_pw: - if keychain_ok: - delete_token("zwift") - zwift_token = DATA_DIR / "zwift_token.json" - if zwift_token.exists(): - zwift_token.unlink() -``` - -Update the "changed" list: - -```python - changed = [] - if eufy_pw: - changed.append("Eufy") - if garmin_pw: - changed.append("Garmin") -``` - -To: - -```python - changed = [] - if eufy_pw: - changed.append("Eufy") - if garmin_pw: - changed.append("Garmin") - if zwift_pw: - changed.append("Zwift") -``` - -- [ ] **Step 2: Run the suite** - -```bash -.venv/bin/python -m pytest tests/ -x -``` - -Expected: all green. - -- [ ] **Step 3: Commit** - -```bash -git add eufy_sync/cli.py -git commit -m "feat(cli): extend --update-password to include Zwift" -``` - ---- - -## Task 13: Extend --status to report Zwift health - -**Files:** -- Modify: `eufy_sync/cli.py` - -- [ ] **Step 1: Update _show_status and _print_summary** - -In `eufy_sync/cli.py`, find the Strava section of `_show_status`: - -```python - # Strava token health - if user.strava: - from eufy_sync.strava_client import StravaClient - strava_status = StravaClient(user.strava).token_status() - if strava_status["state"] == "expired": - print("Strava auth: EXPIRED - re-authorize with --reauth strava") - elif strava_status["state"] == "no_session": - print("Strava auth: not authorized - run --setup-strava") - elif strava_status["state"] == "refresh_needed": - print("Strava auth: access token expired, will refresh on next sync") - else: - print("Strava auth: valid (refresh token active)") -``` - -Add the Zwift block right after: - -```python - # Zwift token health (unofficial) - if user.zwift: - from eufy_sync.zwift_client import ZwiftClient - zwift_status = ZwiftClient(user.zwift).token_status() - if zwift_status["state"] == "no_session": - print("Zwift auth: not authorized - first sync will log in") - elif zwift_status["state"] == "expired": - print("Zwift auth: EXPIRED - re-authorize with --reauth zwift") - elif zwift_status["state"] == "refresh_needed": - print("Zwift auth: access token expired, will refresh on next sync") - else: - print("Zwift auth: valid (refresh token active)") -``` - -In `_print_summary`, find the Strava section: - -```python - if user.strava: - from eufy_sync.strava_client import StravaClient - strava_status = StravaClient(user.strava).token_status() - if strava_status["state"] == "expired": - parts.append("Strava token EXPIRED") - elif strava_status["state"] == "no_session": - parts.append("Strava: not authorized") - elif strava_status["state"] == "refresh_needed": - parts.append("Strava token refresh pending") - else: - parts.append("Strava connected") -``` - -Add the Zwift block right after: - -```python - if user.zwift: - from eufy_sync.zwift_client import ZwiftClient - zwift_status = ZwiftClient(user.zwift).token_status() - if zwift_status["state"] == "no_session": - parts.append("Zwift: not authorized") - elif zwift_status["state"] == "expired": - parts.append("Zwift token EXPIRED") - elif zwift_status["state"] == "refresh_needed": - parts.append("Zwift token refresh pending") - else: - parts.append("Zwift connected") -``` - -In the success-notification block of `main()`, find: - -```python - if total > 0: - target_label = " and ".join(n.capitalize() for n in total_counts if total_counts[n] > 0) - _notify("eufy-sync", f"Synced {total} measurement{'s' if total != 1 else ''} to {target_label}") -``` - -No change needed here - it already iterates whatever is in `total_counts`. Zwift entries appear automatically. - -- [ ] **Step 2: Run the suite** - -```bash -.venv/bin/python -m pytest tests/ -x -``` - -Expected: all green. - -- [ ] **Step 3: Commit** - -```bash -git add eufy_sync/cli.py -git commit -m "feat(cli): report Zwift health in --status and sync summary" -``` - ---- - -## Task 14: --uninstall clears Zwift keychain + token file - -**Files:** -- Modify: `eufy_sync/cli.py` -- Modify: `tests/test_cli.py` - -- [ ] **Step 1: Update the existing _uninstall test to expect Zwift keychain cleanup** - -In `tests/test_cli.py`, find `test_uninstall_clears_keychain_for_configured_user_name`. Add Zwift to the config: - -```python - _write_config(config_path, { - "users": [{ - "name": "elias", - "eufy": {"email": "e@example.com"}, - "garmin": {"email": "g@example.com"}, - "zwift": {"email": "z@example.com"}, - }], - }) -``` - -And assert Zwift is deleted: - -```python - assert "elias:eufy" in deleted_accounts - assert "elias:garmin" in deleted_accounts - assert "elias:zwift" in deleted_accounts -``` - -- [ ] **Step 2: Run, confirm fail** - -```bash -.venv/bin/python -m pytest tests/test_cli.py::test_uninstall_clears_keychain_for_configured_user_name -xvs -``` - -Expected: `AssertionError: assert 'elias:zwift' in {'elias:eufy', 'elias:garmin'}`. - -- [ ] **Step 3: Update _uninstall** - -In `eufy_sync/cli.py`, find the keychain cleanup in `_uninstall`: - -```python - from eufy_sync.credentials import delete_password, delete_token, _keyring_available - if _keyring_available(): - for name in user_names: - for suffix in ["eufy", "garmin"]: - delete_password(f"{name}:{suffix}") - delete_token("eufy") - delete_token("garmin") - delete_token("strava") -``` - -Replace with: - -```python - from eufy_sync.credentials import delete_password, delete_token, _keyring_available - if _keyring_available(): - for name in user_names: - for suffix in ["eufy", "garmin", "zwift"]: - delete_password(f"{name}:{suffix}") - delete_token("eufy") - delete_token("garmin") - delete_token("strava") - delete_token("zwift") -``` - -- [ ] **Step 4: Run the suite** - -```bash -.venv/bin/python -m pytest tests/ -x -``` - -Expected: all green. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/cli.py tests/test_cli.py -git commit -m "feat(cli): clear Zwift keychain entries on --uninstall" -``` - ---- - -## Task 15: README update - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Add Zwift to the opening description and a "Sync targets" section** - -In `README.md`, find the opening description: - -``` -Syncs body composition data from a Eufy smart scale to Garmin Connect and/or Strava. Weight, body fat %, muscle mass, bone mass, hydration, BMR, visceral fat, and metabolic age all come through to Garmin. Strava gets weight updates. -``` - -Replace with: - -``` -Syncs body composition data from a Eufy smart scale to Garmin Connect, Strava, and Zwift. Weight, body fat %, muscle mass, bone mass, hydration, BMR, visceral fat, and metabolic age all come through to Garmin. Strava and Zwift get weight updates. -``` - -After the "## The problem" section, add a new section: - -``` -## Sync targets - -| Target | What gets synced | API stability | -|--------|------------------|---------------| -| Garmin Connect | Full body composition (FIT upload) | Stable | -| Strava | Current weight (`PUT /athlete`) | Stable | -| Zwift | Current weight (`PUT /api/profiles/me`) | Unofficial, may break | - -Zwift has no public API for third-party tools. eufy-sync uses a community-reverse-engineered endpoint that could change with any Zwift release. If Zwift breaks, the other two targets keep working. -``` - -In the "## Usage" section, find the existing command list and add `--setup-zwift`: - -``` -eufy-sync --setup-strava # connect Strava (add to existing setup) -``` - -Add right after it: - -``` -eufy-sync --setup-zwift # connect Zwift (add to existing setup) -``` - -- [ ] **Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: add Zwift to README with unofficial-API caveat" -``` - ---- - -## Final Verification - -- [ ] **Step 1: Run full suite** - -```bash -.venv/bin/python -m pytest tests/ -v -``` - -Expected: all tests pass. - -- [ ] **Step 2: Sanity-check the CLI doesn't crash** - -```bash -.venv/bin/eufy-sync --help -.venv/bin/eufy-sync --version -``` - -Expected: both print without traceback. - -- [ ] **Step 3: Push branch and open PR** - -```bash -git push -u origin -gh pr create --title "feat: add Zwift weight sync" --body "$(cat <<'EOF' -## Summary -Adds Zwift as a third sync target alongside Garmin and Strava. Reverse-engineered Keycloak password-grant auth + PUT /api/profiles/me, one write per sync, per-target failure isolation. - -See [the design doc](docs/superpowers/specs/2026-05-17-zwift-weight-sync-design.md) for context. - -## Test plan -- [x] Unit tests for ZwiftClient (auth, refresh, update_weight, token_status) -- [x] Sync integration tests (one-PUT-per-sync, failure isolation) -- [x] Config parsing tests for Zwift section -- [ ] Manual: run \`eufy-sync --setup-zwift\` against a real Zwift account and verify weight updates on the Zwift website - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -- [ ] **Step 4: Wait for CI, merge, clean up** - -```bash -gh pr checks --watch -gh api -X PUT /repos/sturimcode/eufy-sync/pulls//merge -f merge_method=squash -gh api -X DELETE /repos/sturimcode/eufy-sync/git/refs/heads/ -``` diff --git a/docs/superpowers/plans/2026-06-18-eufy-profile-selection.md b/docs/superpowers/plans/2026-06-18-eufy-profile-selection.md deleted file mode 100644 index 87faa49..0000000 --- a/docs/superpowers/plans/2026-06-18-eufy-profile-selection.md +++ /dev/null @@ -1,767 +0,0 @@ -# Eufy Profile Selection Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stop syncing every household member's weigh-in by letting the user select which Eufy profile is theirs, and refusing to sync when the profile is ambiguous. - -**Architecture:** Add an optional `customer_id` to `EufyConfig`. `EufyClient` learns to list the profiles on an account and to filter `fetch_measurements` to the selected one, raising a non-retryable `AmbiguousProfileError` when several profiles exist and none is chosen. The CLI gains a `--select-profile` command and a wizard prompt that share one picker, and the sync loop renders the picker when it hits the ambiguous case. - -**Tech Stack:** Python 3.12+, httpx, pytest. Same surface as the rest of `eufy-sync`. - -**Source spec:** `docs/superpowers/specs/2026-06-18-eufy-profile-selection-design.md` - ---- - -## File Structure - -**Modify:** -- `eufy_sync/config.py` — add `EufyConfig.customer_id`, parse it in `load_config` -- `eufy_sync/eufy_client.py` — add `EufyProfile`, `AmbiguousProfileError`, `_get_records`, `_parse_all`, `_profiles_from`, `list_profiles`; rework `fetch_measurements` to filter / detect ambiguity -- `eufy_sync/sync.py` — teach `_is_permanent` to treat `AmbiguousProfileError` as non-retryable -- `eufy_sync/cli.py` — add `_format_profile`, `_prompt_profile_choice`, `_select_profile`, the `--select-profile` flag and dispatch, wizard profile detection, and the sync-loop ambiguous handler -- `README.md` — document `--select-profile` and the shared-account behavior -- `tests/test_config.py`, `tests/test_eufy_client.py`, `tests/test_sync.py`, `tests/test_cli.py` — coverage - ---- - -## Task 1: Add customer_id to EufyConfig - -**Files:** -- Modify: `eufy_sync/config.py` -- Test: `tests/test_config.py` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_config.py`: - -```python -@patch("eufy_sync.credentials._keyring_available", return_value=False) -def test_load_config_parses_customer_id(_keyring, tmp_path: Path): - path = tmp_path / "config.yaml" - _write(path, { - "users": [{ - "name": "default", - "eufy": {"email": "e@example.com", "password": "pw", "customer_id": "cust-42"}, - "garmin": {"email": "g@example.com", "password": "pw"}, - }], - }) - cfg = load_config(path) - assert cfg.users[0].eufy.customer_id == "cust-42" - - -@patch("eufy_sync.credentials._keyring_available", return_value=False) -def test_load_config_customer_id_defaults_none(_keyring, tmp_path: Path): - path = tmp_path / "config.yaml" - _write(path, { - "users": [{ - "name": "default", - "eufy": {"email": "e@example.com", "password": "pw"}, - "garmin": {"email": "g@example.com", "password": "pw"}, - }], - }) - cfg = load_config(path) - assert cfg.users[0].eufy.customer_id is None -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `.venv/bin/python -m pytest tests/test_config.py::test_load_config_parses_customer_id -xvs` -Expected: FAIL with `TypeError: __init__() got an unexpected keyword argument 'customer_id'` (after Step 3 wiring) or `AssertionError`/`AttributeError` before it. - -- [ ] **Step 3: Add the field and parse it** - -In `eufy_sync/config.py`, change the `EufyConfig` dataclass: - -```python -@dataclass -class EufyConfig: - email: str - password: str - customer_id: str | None = None -``` - -In `load_config`, change the `EufyConfig(...)` construction inside the `users.append(...)` call to pass `customer_id`: - -```python - eufy=EufyConfig( - email=u["eufy"]["email"], - password=_get_password(name, "eufy", u["eufy"]["email"], u["eufy"].get("password")), - customer_id=u["eufy"].get("customer_id"), - ), -``` - -- [ ] **Step 4: Run tests** - -Run: `.venv/bin/python -m pytest tests/test_config.py -x` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/config.py tests/test_config.py -git commit -m "feat(config): add optional eufy.customer_id for profile selection" -``` - ---- - -## Task 2: EufyProfile, AmbiguousProfileError, and profile listing - -Split the network read out of `fetch_measurements` so profile detection and measurement fetching share one path, and add the listing helper. - -**Files:** -- Modify: `eufy_sync/eufy_client.py` -- Test: `tests/test_eufy_client.py` - -- [ ] **Step 1: Write the failing test** - -Append to `tests/test_eufy_client.py`: - -```python -from unittest.mock import patch - -from eufy_sync.config import EufyConfig -from eufy_sync.eufy_client import AmbiguousProfileError, EufyProfile - - -def _client(customer_id=None): - c = EufyClient.__new__(EufyClient) - c.config = EufyConfig(email="e@example.com", password="pw", customer_id=customer_id) - return c - - -def _record(customer_id, weight_dg, update_time): - return { - "customer_id": customer_id, - "device_id": "d", - "update_time": update_time, - "scale_data": {"weight": weight_dg}, - } - - -def test_list_profiles_groups_by_customer_id_newest_first(): - c = _client() - records = [_record("a", 800, 100), _record("a", 810, 200), _record("b", 600, 150)] - with patch.object(c, "_get_records", return_value=records): - profiles = c.list_profiles() - assert {p.customer_id for p in profiles} == {"a", "b"} - a = next(p for p in profiles if p.customer_id == "a") - assert a.last_weight_kg == 81.0 # most recent record for "a" (810 -> 81.0) - assert profiles[0].last_measured >= profiles[1].last_measured # newest first - assert isinstance(profiles[0], EufyProfile) -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py::test_list_profiles_groups_by_customer_id_newest_first -xvs` -Expected: FAIL with `ImportError: cannot import name 'AmbiguousProfileError'` (or `EufyProfile`). - -- [ ] **Step 3: Add the dataclass, exception, and helpers** - -In `eufy_sync/eufy_client.py`, after the `EufyMeasurement` dataclass, add: - -```python -@dataclass -class EufyProfile: - customer_id: str - last_measured: datetime - last_weight_kg: float - name: str | None = None - - -class AmbiguousProfileError(Exception): - """Several Eufy profiles exist on the account and none has been selected. - - Carries the detected profiles so the CLI can show a picker. Not retryable. - """ - - def __init__(self, profiles: list["EufyProfile"]): - self.profiles = profiles - super().__init__( - f"{len(profiles)} Eufy profiles found but none selected. " - "Run: eufy-sync --select-profile" - ) -``` - -Inside `EufyClient`, add the read/parse/group helpers and `list_profiles`. Place them just above the existing `fetch_measurements`: - -```python - def _get_records(self, after_timestamp: int | None) -> list[dict]: - if not self.access_token or not self.user_id: - raise RuntimeError("Must authenticate before fetching measurements") - - params = {} - if after_timestamp is not None: - params["after"] = str(after_timestamp) - - resp = self._client.get( - f"{BASE_URL}/device/data", - params=params, - headers={"Token": self.access_token, "Uid": self.user_id}, - ) - - needs_reauth = resp.status_code in (401, 403) - if not needs_reauth and resp.status_code == 200: - try: - needs_reauth = resp.json().get("res_code") not in (1, None) - except Exception: - pass - if needs_reauth: - logger.warning("Eufy token rejected, re-authenticating...") - self._clear_cached_token() - self._fresh_login() - resp = self._client.get( - f"{BASE_URL}/device/data", - params=params, - headers={"Token": self.access_token, "Uid": self.user_id}, - ) - - resp.raise_for_status() - body = resp.json() - if body.get("res_code") != 1: - raise RuntimeError(f"Eufy fetch failed: {body.get('message', 'unknown error')}") - - raw_records = body.get("data", []) - logger.info("Fetched %d raw measurements from Eufy", len(raw_records)) - return raw_records - - def _parse_all(self, records: list[dict]) -> list[EufyMeasurement]: - out = [] - for record in records: - m = self._parse_record(record) - if m is not None: - out.append(m) - return out - - def _profiles_from(self, measurements: list[EufyMeasurement]) -> list[EufyProfile]: - latest: dict[str, EufyMeasurement] = {} - for m in measurements: - cur = latest.get(m.customer_id) - if cur is None or m.timestamp > cur.timestamp: - latest[m.customer_id] = m - profiles = [ - EufyProfile( - customer_id=m.customer_id, - last_measured=m.timestamp, - last_weight_kg=m.weight_kg, - ) - for m in latest.values() - ] - profiles.sort(key=lambda p: p.last_measured, reverse=True) - return profiles - - def list_profiles(self) -> list[EufyProfile]: - """Return one profile per customer_id seen in the full history, newest first.""" - records = self._get_records(None) - return self._profiles_from(self._parse_all(records)) -``` - -- [ ] **Step 4: Run tests** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py -x` -Expected: PASS (new test green, existing parse tests still green). - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/eufy_client.py tests/test_eufy_client.py -git commit -m "feat(eufy): add EufyProfile, AmbiguousProfileError, and list_profiles" -``` - ---- - -## Task 3: Filter fetch_measurements by profile, raise when ambiguous - -**Files:** -- Modify: `eufy_sync/eufy_client.py` -- Test: `tests/test_eufy_client.py` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_eufy_client.py`: - -```python -import pytest - - -def test_fetch_filters_to_configured_profile(): - c = _client(customer_id="a") - records = [_record("a", 800, 100), _record("b", 600, 150)] - with patch.object(c, "_get_records", return_value=records): - measurements = c.fetch_measurements() - assert {m.customer_id for m in measurements} == {"a"} - - -def test_fetch_single_profile_returns_all_when_unconfigured(): - c = _client() - records = [_record("a", 800, 100), _record("a", 810, 200)] - with patch.object(c, "_get_records", return_value=records): - measurements = c.fetch_measurements() - assert len(measurements) == 2 - - -def test_fetch_raises_ambiguous_when_multiple_profiles_unconfigured(): - c = _client() - records = [_record("a", 800, 100), _record("b", 600, 150)] - with patch.object(c, "_get_records", return_value=records): - with pytest.raises(AmbiguousProfileError) as exc_info: - c.fetch_measurements() - assert {p.customer_id for p in exc_info.value.profiles} == {"a", "b"} - - -def test_fetch_single_profile_windowed_by_after_timestamp(): - c = _client() - old = _record("a", 800, 1_000) # long ago - new = _record("a", 810, 2_000_000_000) # year 2033 - with patch.object(c, "_get_records", return_value=[old, new]): - measurements = c.fetch_measurements(after_timestamp=1_500_000_000) - assert len(measurements) == 1 - assert measurements[0].weight_kg == 81.0 -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py::test_fetch_raises_ambiguous_when_multiple_profiles_unconfigured -xvs` -Expected: FAIL — the current `fetch_measurements` returns all records and never raises. - -- [ ] **Step 3: Replace fetch_measurements** - -In `eufy_sync/eufy_client.py`, replace the entire existing `fetch_measurements` method body with: - -```python - def fetch_measurements(self, after_timestamp: int | None = None) -> list[EufyMeasurement]: - if self.config.customer_id: - measurements = self._parse_all(self._get_records(after_timestamp)) - return [m for m in measurements if m.customer_id == self.config.customer_id] - - # No profile selected: read full history so the profile count is reliable - # even when only one person has weighed in recently. - measurements = self._parse_all(self._get_records(None)) - distinct = {m.customer_id for m in measurements} - if len(distinct) > 1: - raise AmbiguousProfileError(self._profiles_from(measurements)) - - if after_timestamp is not None: - cutoff = datetime.fromtimestamp(after_timestamp, tz=timezone.utc) - measurements = [m for m in measurements if m.timestamp >= cutoff] - return measurements -``` - -- [ ] **Step 4: Run tests** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py -x` -Expected: PASS (all five fetch/list tests plus the original parse tests). - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/eufy_client.py tests/test_eufy_client.py -git commit -m "feat(eufy): filter fetch by profile, raise AmbiguousProfileError when unset" -``` - ---- - -## Task 4: Treat AmbiguousProfileError as non-retryable - -`fetch_measurements` runs inside `_retry`, so the ambiguous signal must surface immediately instead of retrying three times. - -**Files:** -- Modify: `eufy_sync/sync.py` -- Test: `tests/test_sync.py` - -- [ ] **Step 1: Write the failing test** - -Append to `tests/test_sync.py`: - -```python -def test_ambiguous_profile_error_is_permanent(): - from eufy_sync.sync import _is_permanent - from eufy_sync.eufy_client import AmbiguousProfileError - assert _is_permanent(AmbiguousProfileError([])) is True -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `.venv/bin/python -m pytest tests/test_sync.py::test_ambiguous_profile_error_is_permanent -xvs` -Expected: FAIL with `assert None is True` (or `False is True`). - -- [ ] **Step 3: Recognize the exception** - -In `eufy_sync/sync.py`, change the top import: - -```python -from eufy_sync.eufy_client import EufyClient -``` - -to: - -```python -from eufy_sync.eufy_client import AmbiguousProfileError, EufyClient -``` - -Then update `_is_permanent`: - -```python -def _is_permanent(exc: BaseException) -> bool: - if isinstance(exc, (PermanentSyncError, AmbiguousProfileError)): - return True - if isinstance(exc, httpx.HTTPStatusError): - status = exc.response.status_code - # 4xx is client error - won't recover. 429 is the exception (rate-limited). - return 400 <= status < 500 and status != 429 - return False -``` - -- [ ] **Step 4: Run tests** - -Run: `.venv/bin/python -m pytest tests/test_sync.py -x` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add eufy_sync/sync.py tests/test_sync.py -git commit -m "fix(sync): treat AmbiguousProfileError as permanent so it isn't retried" -``` - ---- - -## Task 5: CLI picker helpers and --select-profile command - -**Files:** -- Modify: `eufy_sync/cli.py` -- Test: `tests/test_cli.py` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_cli.py` (it already imports `_write_config` from `eufy_sync.cli` and `MagicMock`, `patch`, `yaml`, `Path`; add any missing imports at the top of the file): - -```python -def test_prompt_profile_choice_returns_selected_customer_id(): - from datetime import datetime, timezone - from unittest.mock import patch - from eufy_sync.cli import _prompt_profile_choice - from eufy_sync.eufy_client import EufyProfile - profiles = [ - EufyProfile("cid-a", datetime(2026, 6, 1, tzinfo=timezone.utc), 80.0), - EufyProfile("cid-b", datetime(2026, 6, 2, tzinfo=timezone.utc), 62.0), - ] - with patch("builtins.input", return_value="2"): - assert _prompt_profile_choice(profiles) == "cid-b" - - -@patch("eufy_sync.credentials._keyring_available", return_value=False) -def test_select_profile_writes_chosen_customer_id(_keyring, tmp_path: Path): - from datetime import datetime, timezone - from unittest.mock import MagicMock, patch - from eufy_sync.cli import _select_profile, _write_config - from eufy_sync.eufy_client import EufyProfile - - cfg_path = tmp_path / "config.yaml" - _write_config(cfg_path, { - "users": [{ - "name": "default", - "eufy": {"email": "e@example.com", "password": "pw"}, - "garmin": {"email": "g@example.com", "password": "pw"}, - }], - }) - - fake = MagicMock() - fake.list_profiles.return_value = [ - EufyProfile("cid-a", datetime(2026, 6, 2, tzinfo=timezone.utc), 80.0), - EufyProfile("cid-b", datetime(2026, 6, 1, tzinfo=timezone.utc), 62.0), - ] - - with patch("eufy_sync.eufy_client.EufyClient", return_value=fake), \ - patch("builtins.input", return_value="1"): - _select_profile(cfg_path) - - written = yaml.safe_load(cfg_path.read_text()) - assert written["users"][0]["eufy"]["customer_id"] == "cid-a" -``` - -- [ ] **Step 2: Run, confirm fail** - -Run: `.venv/bin/python -m pytest tests/test_cli.py::test_prompt_profile_choice_returns_selected_customer_id -xvs` -Expected: FAIL with `ImportError: cannot import name '_prompt_profile_choice'`. - -- [ ] **Step 3: Add the helpers and command** - -In `eufy_sync/cli.py`, after `_setup_strava` (around line 277), add: - -```python -def _format_profile(profile, index: int) -> str: - lb = profile.last_weight_kg * 2.20462 - when = profile.last_measured.strftime("%Y-%m-%d") - label = profile.name or f"profile ...{profile.customer_id[-4:]}" - return f" {index}. {label} - {profile.last_weight_kg:.1f} kg ({lb:.1f} lb), last weigh-in {when}" - - -def _prompt_profile_choice(profiles: list) -> str: - """Print the profiles and return the customer_id the user picks.""" - print("") - print("Multiple profiles were found on this Eufy account:") - for i, p in enumerate(profiles, 1): - print(_format_profile(p, i)) - print("") - while True: - choice = input(f"Which profile is yours? [1-{len(profiles)}] ").strip() - if choice.isdigit() and 1 <= int(choice) <= len(profiles): - return profiles[int(choice) - 1].customer_id - print("Enter a number from the list.") - - -def _select_profile(config_path: Path) -> None: - """Choose which Eufy profile to sync, for an existing install.""" - if not config_path.exists(): - print("No config found. Run eufy-sync first to set up.") - sys.exit(1) - - with open(config_path) as f: - config = yaml.safe_load(f) - - from eufy_sync.config import load_config - from eufy_sync.eufy_client import EufyClient - - cfg = load_config(config_path) - eufy = EufyClient(cfg.users[0].eufy) - try: - eufy.authenticate() - profiles = eufy.list_profiles() - finally: - eufy.close() - - if not profiles: - print("No profiles found yet. Weigh in and open the Eufy app, then try again.") - return - - user = config["users"][0] - user.setdefault("eufy", {}) - if len(profiles) == 1: - user["eufy"]["customer_id"] = profiles[0].customer_id - _write_config(config_path, config) - print("Only one profile found on this account; saved it as yours.") - return - - user["eufy"]["customer_id"] = _prompt_profile_choice(profiles) - _write_config(config_path, config) - print("Saved. Future syncs will use only your profile.") -``` - -- [ ] **Step 4: Wire the flag into argparse and dispatch** - -In `main()`, after the `--setup-strava` argument (line 799), add: - -```python - parser.add_argument("--select-profile", action="store_true", help="Choose which Eufy profile to sync") -``` - -After the Strava setup dispatch block (lines 831-834), add: - -```python - # Handle profile selection - if args.select_profile: - _select_profile(config_path) - return -``` - -- [ ] **Step 5: Run tests** - -Run: `.venv/bin/python -m pytest tests/test_cli.py -x` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add eufy_sync/cli.py tests/test_cli.py -git commit -m "feat(cli): add --select-profile and the shared profile picker" -``` - ---- - -## Task 6: Detect profiles during first-run setup - -**Files:** -- Modify: `eufy_sync/cli.py` - -- [ ] **Step 1: Add profile detection to the wizard** - -In `_first_run_setup`, the config dict is built into `user_config` and then written at: - -```python - config = {"users": [user_config]} - _write_config(config_path, config) -``` - -Immediately BEFORE that `config = {"users": [user_config]}` line, insert: - -```python - # On a shared account, pick the right person before the first sync. - try: - from eufy_sync.config import EufyConfig - from eufy_sync.eufy_client import EufyClient - probe = EufyClient(EufyConfig(email=eufy_email, password=eufy_password)) - try: - probe.authenticate() - profiles = probe.list_profiles() - finally: - probe.close() - if len(profiles) > 1: - user_config["eufy"]["customer_id"] = _prompt_profile_choice(profiles) - except Exception as e: - # Non-fatal: if this fails, the first sync safely stops and prompts. - print(f"Note: could not check Eufy profiles right now ({e}).") -``` - -- [ ] **Step 2: Run the suite** - -Run: `.venv/bin/python -m pytest tests/ -x` -Expected: PASS (no test changes; the wizard's network calls are not exercised by the suite). - -- [ ] **Step 3: Sanity-check the CLI imports cleanly** - -Run: `.venv/bin/python -c "import eufy_sync.cli"` -Expected: no output, no traceback. - -- [ ] **Step 4: Commit** - -```bash -git add eufy_sync/cli.py -git commit -m "feat(cli): detect and pick Eufy profile during first-run setup" -``` - ---- - -## Task 7: Render the picker when a sync hits the ambiguous case - -**Files:** -- Modify: `eufy_sync/cli.py` - -- [ ] **Step 1: Add the handler to the sync loop** - -In `main()`, find the per-user sync loop: - -```python - for user in config.users: - try: - counts = sync_user(user, state, backfill_days=backfill, headless=args.headless, dry_run=args.dry_run) - for target_name, count in counts.items(): - total_counts[target_name] = total_counts.get(target_name, 0) + count - logger.info("User %s: synced %s", user.name, counts) - except Exception as e: - logger.exception("Failed to sync user %s", user.name) - failures.append((user.name, str(e))) -``` - -Insert a specific handler BEFORE the `except Exception` branch: - -```python - except AmbiguousProfileError as e: - print("") - print("Multiple profiles were found on this Eufy account:") - for i, p in enumerate(e.profiles, 1): - print(_format_profile(p, i)) - print("") - print("Nothing was synced. Choose your profile with: eufy-sync --select-profile") - failures.append((user.name, "multiple Eufy profiles; run eufy-sync --select-profile")) -``` - -At the top of `main()`, alongside the other sync imports (the `from eufy_sync.sync import sync_user` line around line 884), add: - -```python - from eufy_sync.eufy_client import AmbiguousProfileError -``` - -- [ ] **Step 2: Run the suite** - -Run: `.venv/bin/python -m pytest tests/ -x` -Expected: PASS. - -- [ ] **Step 3: Sanity-check imports** - -Run: `.venv/bin/python -c "import eufy_sync.cli"` -Expected: no output, no traceback. - -- [ ] **Step 4: Commit** - -```bash -git add eufy_sync/cli.py -git commit -m "feat(cli): show profile picker guidance when a sync is ambiguous" -``` - ---- - -## Task 8: README - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Document the command** - -In `README.md`, in the `## Usage` command list, after the `--setup-strava` line, add: - -``` -eufy-sync --select-profile # choose which Eufy profile to sync (shared scale) -``` - -- [ ] **Step 2: Add a shared-account note** - -In `README.md`, in the `## Known quirks` section, add a paragraph: - -``` -If more than one person uses the same Eufy account, the tool asks which profile is yours during setup, so only your weigh-ins sync. If you set it up before this was added, run `eufy-sync --select-profile` once. Until you choose, a sync that sees several profiles stops and shows them rather than guessing. -``` - -- [ ] **Step 3: Commit** - -```bash -git add README.md -git commit -m "docs: document --select-profile and shared-account behavior" -``` - ---- - -## Final Verification - -- [ ] **Step 1: Run the full suite** - -Run: `.venv/bin/python -m pytest tests/ -v` -Expected: all tests pass, including the new config, eufy_client, sync, and cli tests. - -- [ ] **Step 2: Sanity-check the CLI** - -```bash -.venv/bin/eufy-sync --help -.venv/bin/eufy-sync --version -``` - -Expected: `--help` lists `--select-profile`; both print without a traceback. - -- [ ] **Step 3: Push the branch and open a PR** - -```bash -git push -u origin fix-profile-selection -gh pr create --title "Fix issue #1: sync only the selected Eufy profile" --body "$(cat <<'EOF' -## Summary -On a shared Eufy account the tool synced every household member's weigh-in, so a user got their partner's weight written to their own Garmin/Strava. This adds an optional profile selection. - -- New optional `eufy.customer_id` filters sync to one profile. -- `EufyClient.list_profiles()` powers a picker; `fetch_measurements` filters to the selected profile. -- When several profiles exist and none is selected, the sync stops with a non-retryable `AmbiguousProfileError` instead of guessing. -- `--select-profile` lets existing installs choose; the first-run wizard prompts automatically. - -Closes #1. See `docs/superpowers/specs/2026-06-18-eufy-profile-selection-design.md`. - -## Test plan -- [x] `pytest tests/` green -- [ ] Manual: run `--select-profile` against a real shared account and confirm only the chosen profile syncs -EOF -)" -``` - -- [ ] **Step 4: Watch CI, merge, clean up** - -```bash -gh pr checks --watch -gh api -X PUT /repos/sturimcode/eufy-sync/pulls//merge -f merge_method=squash -gh api -X DELETE /repos/sturimcode/eufy-sync/git/refs/heads/fix-profile-selection -``` diff --git a/docs/superpowers/plans/2026-06-18-garmin-auth-curl-cffi.md b/docs/superpowers/plans/2026-06-18-garmin-auth-curl-cffi.md deleted file mode 100644 index fea4f98..0000000 --- a/docs/superpowers/plans/2026-06-18-garmin-auth-curl-cffi.md +++ /dev/null @@ -1,845 +0,0 @@ -# Garmin Auth Swap Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the Playwright browser login with the browser-free `python-garminconnect` library, letting it own Garmin login, token refresh, and the body-composition upload while keeping the same-date duplicate check. - -**Architecture:** `garmin_auth.py` becomes a thin manager around `garminconnect.Garmin` that persists the library's token blob to the keychain. `garmin_client.py` keeps its public surface but delegates upload to `add_body_composition` and dedup to the library's read API. `fit.py` and the Playwright code are deleted. `sync.py` changes one kwarg. - -**Tech Stack:** Python 3.12+, `garminconnect>=0.3.6`, `curl_cffi`, keyring, pytest. The library is mocked in all tests (no network). - -**Source spec:** `docs/superpowers/specs/2026-06-18-garmin-auth-curl-cffi-design.md` - -**Test runner:** `~/.local/bin/uv run pytest`. Run a single file with e.g. `~/.local/bin/uv run pytest tests/test_garmin_auth.py -v`. - ---- - -## File Structure - -**Delete:** -- `eufy_sync/fit.py` — the library builds the FIT file inside `add_body_composition` -- `tests/test_fit.py` - -**Rewrite:** -- `eufy_sync/garmin_auth.py` — `GarminAuth` around `garminconnect.Garmin` + keychain token persistence -- `tests/test_garmin_auth.py` — mock `garminconnect.Garmin` - -**Modify:** -- `eufy_sync/garmin_client.py` — delegate upload/dedup to the library, rename `allow_browser` to `allow_interactive` -- `eufy_sync/sync.py` — one kwarg rename at the Garmin authenticate call -- `eufy_sync/__init__.py` — drop the `FitEncoder` export -- `eufy_sync/cli.py` — remove `_ensure_chromium` + call + wizard browser text; update the Garmin branches of `_reauth`, `_show_status`, `_print_summary` -- `pyproject.toml`, `requirements.txt` — swap dependencies -- `README.md` — rewrite "How Garmin login works" - -**Create:** -- `tests/test_garmin_client.py` — mock the library, assert field mapping and dedup - ---- - -## Task 1: Swap dependencies - -**Files:** -- Modify: `pyproject.toml`, `requirements.txt` - -- [ ] **Step 1: Edit `pyproject.toml` dependencies** - -The `[project]` `dependencies` list currently contains: -```toml -dependencies = [ - "httpx>=0.27.0", - "keyring>=25.0.0", - "playwright>=1.40.0", - "pyyaml>=6.0", -] -``` -Replace the `playwright` line so the list reads: -```toml -dependencies = [ - "httpx>=0.27.0", - "keyring>=25.0.0", - "garminconnect>=0.3.6", - "curl_cffi>=0.7.0", - "pyyaml>=6.0", -] -``` - -- [ ] **Step 2: Edit `requirements.txt`** - -It currently reads: -``` -httpx>=0.27.0 -playwright>=1.40.0 -pyyaml>=6.0 -``` -Replace the `playwright` line: -``` -httpx>=0.27.0 -garminconnect>=0.3.6 -curl_cffi>=0.7.0 -pyyaml>=6.0 -``` - -- [ ] **Step 3: Sync the environment and confirm the library imports** - -Run: -```bash -~/.local/bin/uv run python -c "from garminconnect import Garmin; print('garminconnect ok')" -``` -Expected: prints `garminconnect ok` (uv installs the new deps on first run). If it fails, run `~/.local/bin/uv sync` and retry. - -- [ ] **Step 4: Commit** - -```bash -git add pyproject.toml requirements.txt -git commit -m "build: swap playwright for garminconnect + curl_cffi" -``` - ---- - -## Task 2: Rewrite garmin_auth.py around garminconnect - -**Files:** -- Rewrite: `eufy_sync/garmin_auth.py` -- Rewrite: `tests/test_garmin_auth.py` - -- [ ] **Step 1: Verify the library's auth + token-persistence API on the installed version** - -The exact attribute that holds the token dump/load methods varies by version. Confirm it before writing code: -```bash -~/.local/bin/uv run python -c "import garminconnect, inspect; g=garminconnect.Garmin; print([m for m in dir(g) if not m.startswith('__')])" -~/.local/bin/uv run python -c "import garminconnect, inspect; print(inspect.signature(garminconnect.Garmin.__init__)); print(inspect.signature(garminconnect.Garmin.login))" -``` -CONFIRMED on the installed garminconnect 0.3.6 (verified during planning): the token serialize/deserialize methods are on `garmin.client` — `garmin.client.dumps()` returns a JSON string, `garmin.client.loads(json_str)` restores. (`garmin.garth` does NOT exist on 0.3.6.) The constructor takes `prompt_mfa`, and `login()` does a fresh credential login when called with no `tokenstore`. The code below already uses `garmin.client`; just confirm it still holds on the installed version before relying on it. - -- [ ] **Step 2: Write the failing tests** - -Replace the entire contents of `tests/test_garmin_auth.py` with: - -```python -from __future__ import annotations - -import json -from unittest.mock import MagicMock, patch - -import pytest - -from eufy_sync.garmin_auth import GarminAuth -from eufy_sync.sync import PermanentSyncError - -BLOB = {"di_token": "tok", "di_refresh_token": "rtok", "di_client_id": "cid"} - - -def _auth(): - return GarminAuth("test@example.com", "pw") - - -def test_login_restores_saved_blob_without_fresh_login(monkeypatch): - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: dict(BLOB)) - fake_garmin = MagicMock() - with patch("eufy_sync.garmin_auth.Garmin", return_value=fake_garmin) as ctor: - result = auth.login(interactive=True) - assert result is fake_garmin - fake_garmin.login.assert_not_called() # restored from blob, no fresh login - assert ctor.call_count == 1 - - -def test_login_fresh_when_no_blob_and_interactive(monkeypatch): - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: None) - saved = {} - monkeypatch.setattr(auth, "_save_token", lambda g: saved.setdefault("called", True)) - fake_garmin = MagicMock() - with patch("eufy_sync.garmin_auth.Garmin", return_value=fake_garmin): - auth.login(interactive=True) - fake_garmin.login.assert_called_once() - assert saved.get("called") is True - - -def test_login_raises_when_no_blob_and_not_interactive(monkeypatch): - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: None) - with patch("eufy_sync.garmin_auth.Garmin", return_value=MagicMock()): - with pytest.raises(PermanentSyncError): - auth.login(interactive=False) - - -def test_token_status_valid_with_blob(monkeypatch): - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: dict(BLOB)) - assert auth.token_status()["state"] == "valid" - - -def test_token_status_no_session_without_blob(monkeypatch): - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: None) - assert auth.token_status()["state"] == "no_session" -``` - -- [ ] **Step 3: Run, confirm failure** - -Run: `~/.local/bin/uv run pytest tests/test_garmin_auth.py -x` -Expected: import/attribute errors (the old `TokenPair`/`GarminSession` are gone; `login`/`_load_token` not yet defined). - -- [ ] **Step 4: Replace the entire contents of `eufy_sync/garmin_auth.py`** - -```python -"""Garmin authentication via the python-garminconnect library (browser-free). - -The library logs in with curl_cffi TLS impersonation, handles MFA via a -callback, auto-refreshes the access token, and uploads body composition. -We persist its token blob to the system keychain so logins are rare. -""" -from __future__ import annotations - -import json -import logging -import os -from pathlib import Path - -from garminconnect import Garmin - -logger = logging.getLogger(__name__) - - -def _mfa_prompt() -> str: - return input("Garmin MFA code (check your email): ").strip() - - -class GarminAuth: - """Manages a garminconnect.Garmin client and its persisted token blob.""" - - def __init__(self, email: str, password: str, session_path: Path | None = None): - self.email = email - self.password = password - self.session_path = session_path or Path.home() / ".garmin-sync" / "session.json" - - def login(self, interactive: bool = True) -> Garmin: - """Return an authenticated Garmin client. - - Restores the saved token blob if present; otherwise does a fresh - login (prompting for MFA) when interactive, or raises when not. - """ - garmin = Garmin(self.email, self.password, prompt_mfa=_mfa_prompt) - - blob = self._load_token() - if blob is not None: - try: - garmin.client.loads(json.dumps(blob)) - logger.info("Restored saved Garmin session") - return garmin - except Exception as e: - logger.warning("Saved Garmin token unusable (%s); re-logging in", e) - - if not interactive: - from eufy_sync.sync import PermanentSyncError - raise PermanentSyncError("Garmin login needed; run: eufy-sync --reauth") - - garmin.login() - self._save_token(garmin) - logger.info("Authenticated to Garmin as %s", self.email) - return garmin - - def force_reauth(self) -> Garmin: - """Clear the stored token and do a fresh interactive login.""" - self._clear_token() - garmin = Garmin(self.email, self.password, prompt_mfa=_mfa_prompt) - garmin.login() - self._save_token(garmin) - logger.info("Re-authenticated to Garmin as %s", self.email) - return garmin - - def token_status(self) -> dict: - """Return token health. The library auto-refreshes, so the states - collapse to valid (a blob exists) or no_session.""" - if self._load_token() is not None: - return {"state": "valid", "days_remaining": None} - return {"state": "no_session", "days_remaining": None} - - def _load_token(self) -> dict | None: - from eufy_sync.credentials import get_token, _keyring_available - # A valid library blob has di_token as a STRING. The old Playwright - # session also had a "di_token" key but as a nested dict; reject it so - # those users migrate cleanly to a fresh login. - if _keyring_available(): - data = get_token("garmin") - if data and isinstance(data.get("di_token"), str): - return data - return None - if not self.session_path.exists(): - return None - try: - data = json.loads(self.session_path.read_text()) - return data if isinstance(data.get("di_token"), str) else None - except Exception: - return None - - def _save_token(self, garmin: Garmin) -> None: - blob = json.loads(garmin.client.dumps()) - from eufy_sync.credentials import store_token, _keyring_available - if _keyring_available(): - store_token("garmin", blob) - if self.session_path.exists(): - self.session_path.unlink() - return - self.session_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - fd = os.open(str(self.session_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w") as f: - f.write(json.dumps(blob, indent=2)) - - def _clear_token(self) -> None: - from eufy_sync.credentials import delete_token, _keyring_available - if _keyring_available(): - delete_token("garmin") - if self.session_path.exists(): - self.session_path.unlink() -``` - -Note on the import: `sync.py` imports from `garmin_client` (which imports `garmin_auth`), so importing `PermanentSyncError` from `sync` at module top would risk a cycle. Import it lazily inside `login()` at the point of `raise`, matching how `eufy_client.py` already does it. - -- [ ] **Step 5: Run tests** - -Run: `~/.local/bin/uv run pytest tests/test_garmin_auth.py -x` -Expected: all 5 pass. - -- [ ] **Step 6: Commit** - -```bash -git add eufy_sync/garmin_auth.py tests/test_garmin_auth.py -git commit -m "feat(garmin): rewrite GarminAuth around python-garminconnect" -``` - ---- - -## Task 3: Rewrite garmin_client.py to delegate upload + dedup - -**Files:** -- Modify: `eufy_sync/garmin_client.py` -- Create: `tests/test_garmin_client.py` - -- [ ] **Step 1: Confirm the upload/read method signatures on the installed library** - -```bash -~/.local/bin/uv run python -c "import garminconnect, inspect; print(inspect.signature(garminconnect.Garmin.add_body_composition))" -~/.local/bin/uv run python -c "import garminconnect; print([m for m in dir(garminconnect.Garmin) if 'body' in m or 'weigh' in m])" -``` -Expected: `add_body_composition` takes `timestamp, weight, percent_fat, percent_hydration, visceral_fat_mass, bone_mass, muscle_mass, basal_met, active_met, physique_rating, metabolic_age, visceral_fat_rating, bmi`. Confirm the read method name for dedup (`get_body_composition(startdate, enddate)`); use whatever the installed version exposes for reading weigh-ins by date. - -Also confirm the library's authentication exception name (used below to detect a dead restored session): -```bash -~/.local/bin/uv run python -c "import garminconnect; print([n for n in dir(garminconnect) if 'Error' in n or 'Exception' in n])" -``` -Expected: `GarminConnectAuthenticationError` is present. Use that exact name in the upload guard below; if the installed version names it differently, use that name. - -- [ ] **Step 2: Write the failing tests** - -Create `tests/test_garmin_client.py`: - -```python -from __future__ import annotations - -from datetime import datetime, timezone -from unittest.mock import MagicMock, patch - -from eufy_sync.config import GarminConfig -from eufy_sync.garmin_client import GarminClient -from eufy_sync.transform import GarminBodyComposition - - -def _client_with_fake_garmin(fake_garmin): - client = GarminClient(GarminConfig(email="g@example.com", password="pw")) - client._garmin = fake_garmin - return client - - -def test_upload_maps_fields_to_add_body_composition(): - fake = MagicMock() - fake.add_body_composition.return_value = {"ok": True} - client = _client_with_fake_garmin(fake) - bc = GarminBodyComposition( - timestamp="2026-06-10T08:00:00+00:00", - weight=86.2, - percent_fat=18.5, - percent_hydration=55.3, - visceral_fat_rating=8.0, - bone_mass=3.2, - muscle_mass=45.2, - basal_met=1650, - metabolic_age=28, - bmi=None, - ) - client.upload_body_composition(bc) - kwargs = fake.add_body_composition.call_args.kwargs - assert kwargs["weight"] == 86.2 - assert kwargs["timestamp"] == "2026-06-10T08:00:00+00:00" - assert kwargs["percent_fat"] == 18.5 - assert kwargs["visceral_fat_rating"] == 8.0 - assert kwargs["basal_met"] == 1650 - - -def test_has_weight_on_date_true_when_entry_exists(): - fake = MagicMock() - fake.get_body_composition.return_value = {"dateWeightList": [{"weight": 86000}]} - client = _client_with_fake_garmin(fake) - assert client.has_weight_on_date(datetime(2026, 6, 10, tzinfo=timezone.utc)) is True - - -def test_has_weight_on_date_false_when_empty(): - fake = MagicMock() - fake.get_body_composition.return_value = {"dateWeightList": []} - client = _client_with_fake_garmin(fake) - assert client.has_weight_on_date(datetime(2026, 6, 10, tzinfo=timezone.utc)) is False - - -def test_has_weight_on_date_false_on_read_error(): - fake = MagicMock() - fake.get_body_composition.side_effect = RuntimeError("boom") - client = _client_with_fake_garmin(fake) - assert client.has_weight_on_date(datetime(2026, 6, 10, tzinfo=timezone.utc)) is False - - -def test_authenticate_uses_auth_login(): - client = GarminClient(GarminConfig(email="g@example.com", password="pw")) - fake_garmin = MagicMock() - with patch.object(client._auth, "login", return_value=fake_garmin) as login: - client.authenticate(allow_interactive=False) - login.assert_called_once_with(interactive=False) - assert client._garmin is fake_garmin - - -def test_upload_reauths_and_retries_on_auth_error_when_interactive(): - from garminconnect import GarminConnectAuthenticationError - client = GarminClient(GarminConfig(email="g@example.com", password="pw")) - dead = MagicMock() - dead.add_body_composition.side_effect = GarminConnectAuthenticationError("dead") - fresh = MagicMock() - fresh.add_body_composition.return_value = {"ok": True} - client._garmin = dead - client._allow_interactive = True - bc = GarminBodyComposition(timestamp="2026-06-10T08:00:00+00:00", weight=80.0) - with patch.object(client._auth, "force_reauth", return_value=fresh) as reauth: - client.upload_body_composition(bc) - reauth.assert_called_once() - fresh.add_body_composition.assert_called_once() # retried on the fresh client - assert client._garmin is fresh - - -def test_upload_raises_permanent_on_auth_error_when_headless(): - from garminconnect import GarminConnectAuthenticationError - from eufy_sync.sync import PermanentSyncError - import pytest - client = GarminClient(GarminConfig(email="g@example.com", password="pw")) - dead = MagicMock() - dead.add_body_composition.side_effect = GarminConnectAuthenticationError("dead") - client._garmin = dead - client._allow_interactive = False - bc = GarminBodyComposition(timestamp="2026-06-10T08:00:00+00:00", weight=80.0) - with pytest.raises(PermanentSyncError): - client.upload_body_composition(bc) -``` - -- [ ] **Step 3: Run, confirm failure** - -Run: `~/.local/bin/uv run pytest tests/test_garmin_client.py -x` -Expected: failures (`_garmin`, new method bodies not present; old code imports `FitEncoder`). - -- [ ] **Step 4: Replace the entire contents of `eufy_sync/garmin_client.py`** - -```python -"""Garmin Connect client. Delegates login, refresh, and upload to -python-garminconnect; keeps a same-date duplicate check. -""" -from __future__ import annotations - -import logging -from datetime import datetime - -from garminconnect import GarminConnectAuthenticationError - -from eufy_sync.config import GarminConfig -from eufy_sync.garmin_auth import GarminAuth -from eufy_sync.transform import GarminBodyComposition - -logger = logging.getLogger(__name__) - - -class GarminClient: - def __init__(self, config: GarminConfig): - self.config = config - self._auth = GarminAuth(config.email, config.password) - self._garmin = None - self._allow_interactive = True - - def authenticate(self, allow_interactive: bool = True) -> None: - self._allow_interactive = allow_interactive - self._garmin = self._auth.login(interactive=allow_interactive) - logger.info("Authenticated to Garmin Connect as %s", self.config.email) - - def has_weight_on_date(self, dt: datetime) -> bool: - """Whether Garmin already has a weight entry for the date.""" - date_str = dt.strftime("%Y-%m-%d") - try: - data = self._garmin.get_body_composition(date_str, date_str) - entries = data.get("dateWeightList", data.get("dailyWeightSummaries", [])) - return len(entries) > 0 - except Exception as e: - # Fail open: let the upload proceed; Garmin de-dupes by timestamp. - logger.warning("Garmin duplicate-check failed for %s: %s", date_str, e) - return False - - def _add_body_composition(self, body_comp: GarminBodyComposition): - return self._garmin.add_body_composition( - timestamp=body_comp.timestamp, - weight=body_comp.weight, - percent_fat=body_comp.percent_fat, - percent_hydration=body_comp.percent_hydration, - visceral_fat_rating=body_comp.visceral_fat_rating, - bone_mass=body_comp.bone_mass, - muscle_mass=body_comp.muscle_mass, - basal_met=body_comp.basal_met, - metabolic_age=body_comp.metabolic_age, - bmi=body_comp.bmi, - ) - - def upload_body_composition(self, body_comp: GarminBodyComposition) -> dict: - try: - result = self._add_body_composition(body_comp) - except GarminConnectAuthenticationError: - # Restored session is dead (refresh token expired or revoked). - if not self._allow_interactive: - from eufy_sync.sync import PermanentSyncError - raise PermanentSyncError( - "Garmin session expired; run: eufy-sync --reauth" - ) - logger.info("Garmin session expired; re-authenticating") - self._garmin = self._auth.force_reauth() - result = self._add_body_composition(body_comp) - logger.info( - "Uploaded body comp to Garmin: %.1f kg at %s", - body_comp.weight, body_comp.timestamp, - ) - return result if isinstance(result, dict) else {"status": "ok"} - - def close(self) -> None: - self._garmin = None -``` - -- [ ] **Step 5: Run tests** - -Run: `~/.local/bin/uv run pytest tests/test_garmin_client.py -x` -Expected: all 5 pass. - -- [ ] **Step 6: Commit** - -```bash -git add eufy_sync/garmin_client.py tests/test_garmin_client.py -git commit -m "feat(garmin): delegate upload + dedup to python-garminconnect" -``` - ---- - -## Task 4: Delete fit.py and drop its export - -**Files:** -- Delete: `eufy_sync/fit.py`, `tests/test_fit.py` -- Modify: `eufy_sync/__init__.py` - -- [ ] **Step 1: Delete the FIT encoder and its tests** - -```bash -git rm eufy_sync/fit.py tests/test_fit.py -``` - -- [ ] **Step 2: Remove the FitEncoder export from `eufy_sync/__init__.py`** - -Delete the line: -```python -from eufy_sync.fit import FitEncoder -``` -and remove `"FitEncoder",` from the `__all__` list. - -- [ ] **Step 3: Confirm nothing else imports fit** - -Run: `~/.local/bin/uv run python -c "import eufy_sync, eufy_sync.cli, eufy_sync.garmin_client"` -Expected: no `ModuleNotFoundError`. Also run `grep -rn "from eufy_sync.fit\|import fit\|FitEncoder" eufy_sync tests` and confirm no remaining references. - -- [ ] **Step 4: Run the full suite** - -Run: `~/.local/bin/uv run pytest -q` -Expected: green (sync tests still pass; they mock GarminClient). - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "refactor: delete hand-rolled FIT encoder (library builds it now)" -``` - ---- - -## Task 5: Rename the authenticate kwarg in sync.py - -**Files:** -- Modify: `eufy_sync/sync.py` - -- [ ] **Step 1: Update the Garmin authenticate call** - -In `eufy_sync/sync.py`, the Garmin branch of the authenticate loop reads: -```python - if target_name == "garmin": - client.authenticate(allow_browser=not headless) - else: - client.authenticate() -``` -Change the Garmin line to: -```python - if target_name == "garmin": - client.authenticate(allow_interactive=not headless) - else: - client.authenticate() -``` - -- [ ] **Step 2: Run the full suite** - -Run: `~/.local/bin/uv run pytest -q` -Expected: green. The sync tests mock `GarminClient`, so the kwarg rename flows through. - -- [ ] **Step 3: Commit** - -```bash -git add eufy_sync/sync.py -git commit -m "refactor(sync): rename allow_browser to allow_interactive" -``` - ---- - -## Task 6: Update the CLI (remove Chromium, fix Garmin status text) - -**Files:** -- Modify: `eufy_sync/cli.py` - -- [ ] **Step 1: Delete `_ensure_chromium` and its call** - -Delete the entire `_ensure_chromium` function (the `def _ensure_chromium() -> None:` block, currently around lines 120-137). - -Find its call site, currently: -```python - # Only install Chromium if Garmin is configured - has_garmin = any(u.garmin for u in config.users) - if has_garmin: - _ensure_chromium() -``` -Delete those four lines. (`has_garmin` is recomputed later where the first-run summary needs it; verify with `grep -n "has_garmin" eufy_sync/cli.py` and, if a later use remains, leave that later definition intact. If `has_garmin` is referenced only after this point, add `has_garmin = any(u.garmin for u in config.users)` at the spot it is first used.) - -- [ ] **Step 2: Remove the wizard browser message** - -Delete the line (currently ~line 233): -```python - print("A browser window will open for Garmin login.") -``` -and the surrounding `if garmin_email:` if that print was its only body. (Check: `sed -n '230,236p' eufy_sync/cli.py`. Keep any unrelated lines.) - -- [ ] **Step 3: Fix the Garmin branch of `_reauth`** - -The block currently reads: -```python - if force: - status = auth.token_status() - if status["state"] == "valid": - print(f"Garmin tokens are still valid ({status['days_remaining']}d remaining). Re-authenticate anyway? [y/N] ", end="") - if sys.stdin.isatty(): - answer = input().strip() - if not answer.lower().startswith("y"): - print("Garmin re-auth skipped.") - do_garmin = False - - if do_garmin: - _ensure_chromium() - auth.force_reauth() - print("Done - Garmin tokens saved.") -``` -Replace it with (no Chromium, no days_remaining reference): -```python - if force: - status = auth.token_status() - if status["state"] == "valid": - print("Garmin is already connected. Re-authenticate anyway? [y/N] ", end="") - if sys.stdin.isatty(): - answer = input().strip() - if not answer.lower().startswith("y"): - print("Garmin re-auth skipped.") - do_garmin = False - - if do_garmin: - auth.force_reauth() - print("Done - Garmin tokens saved.") -``` - -- [ ] **Step 4: Fix the Garmin branch of `_print_summary`** - -The block currently reads: -```python - if user.garmin: - from eufy_sync.garmin_auth import GarminAuth - status = GarminAuth(user.garmin.email, user.garmin.password).token_status() - if status["state"] == "expired": - parts.append("Garmin token EXPIRED") - elif status["state"] == "refresh_needed": - parts.append(f"token refresh pending ({status['days_remaining']}d until re-login)") - elif status["days_remaining"] is not None: - parts.append(f"Garmin token valid {status['days_remaining']}d") -``` -Replace it with: -```python - if user.garmin: - from eufy_sync.garmin_auth import GarminAuth - status = GarminAuth(user.garmin.email, user.garmin.password).token_status() - if status["state"] == "valid": - parts.append("Garmin connected") - else: - parts.append("Garmin not connected") -``` - -- [ ] **Step 5: Fix the Garmin branch of `_show_status`** - -The block currently reads: -```python - # Garmin token health - if user.garmin: - from eufy_sync.garmin_auth import GarminAuth - status = GarminAuth(user.garmin.email, user.garmin.password).token_status() - if status["state"] == "expired": - print("Garmin auth: EXPIRED - browser re-login needed") - elif status["state"] == "refresh_needed": - print(f"Garmin auth: access token expired, will refresh on next sync ({status['days_remaining']}d until re-login)") - elif status["state"] == "valid": - print(f"Garmin auth: valid ({status['days_remaining']} days until re-login needed)") - else: - print("Garmin auth: no saved session - first run will open browser") -``` -Replace it with: -```python - # Garmin token health - if user.garmin: - from eufy_sync.garmin_auth import GarminAuth - status = GarminAuth(user.garmin.email, user.garmin.password).token_status() - if status["state"] == "valid": - print("Garmin auth: valid (auto-refreshes; re-login only if it expires)") - else: - print("Garmin auth: not connected - run: eufy-sync --reauth") -``` - -- [ ] **Step 6: Run the suite and a smoke check** - -Run: -```bash -~/.local/bin/uv run pytest -q -~/.local/bin/uv run python -c "import eufy_sync.cli" -~/.local/bin/uv run eufy-sync --help -``` -Expected: tests green; import clean; `--help` runs (no `_ensure_chromium` reference error). Confirm no stray `_ensure_chromium` remains: `grep -n "_ensure_chromium" eufy_sync/cli.py` returns nothing. - -- [ ] **Step 7: Commit** - -```bash -git add eufy_sync/cli.py -git commit -m "feat(cli): drop Chromium install and browser-login text; simplify Garmin status" -``` - ---- - -## Task 7: Rewrite the README Garmin section - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Rewrite "How Garmin login works"** - -Writing rules: no em-dashes; no marketing filler ("delve", "showcase", "seamless", "robust", "leverage", "enhance", "ensure"); plain, specific prose; sentence case. - -Replace the current section: -``` -## How Garmin login works - -Garmin has no official API for writing body composition into Connect, and in March 2026 it put Cloudflare in front of its login, which broke the Python libraries that talked to it. [garth](https://github.com/matin/garth) was [deprecated](https://github.com/matin/garth/discussions/222) and stays that way. [python-garminconnect](https://github.com/cyberjunky/python-garminconnect) has since recovered with a browser-free workaround. - -eufy-sync currently handles the login with Playwright: on first run a Chromium window opens and you log in normally. OAuth2 tokens are saved to your system keychain and refresh on their own for about a year, so after that first login no browser is needed. Body composition then goes up as FIT files through Garmin's upload endpoint. -``` -with: -``` -## How Garmin login works - -Garmin has no official API for writing body composition into Connect. In March 2026 it put Cloudflare in front of its login, which broke the Python libraries that talked to it; [garth](https://github.com/matin/garth) was [deprecated](https://github.com/matin/garth/discussions/222) and stays that way. - -eufy-sync logs in through [python-garminconnect](https://github.com/cyberjunky/python-garminconnect), which gets past Cloudflare without a browser. On first run you enter your Garmin email and password in the terminal, and a code if your account uses two-factor. Tokens are saved to your system keychain and refresh on their own, so later runs need no login. Body composition is uploaded through the same library. -``` - -- [ ] **Step 2: Check the macOS-only note still fits** - -`grep -n "macOS only\|Chromium\|browser" README.md`. If any remaining line implies a browser is needed for Garmin, update it to match the terminal login. Leave unrelated content alone. - -- [ ] **Step 3: Commit** - -```bash -git add README.md -git commit -m "docs: describe the browser-free Garmin login" -``` - ---- - -## Final Verification - -- [ ] **Step 1: Full suite** - -Run: `~/.local/bin/uv run pytest -v` -Expected: all pass. `test_fit.py` is gone; `test_garmin_auth.py` and `test_garmin_client.py` are green. - -- [ ] **Step 2: No Playwright or FIT references remain** - -Run: -```bash -grep -rn "playwright\|Playwright\|FitEncoder\|from eufy_sync.fit\|_ensure_chromium" eufy_sync tests -``` -Expected: no matches. - -- [ ] **Step 3: CLI smoke** - -```bash -~/.local/bin/uv run eufy-sync --help -~/.local/bin/uv run eufy-sync --version -``` -Expected: both print without traceback. - -- [ ] **Step 4: Push and open a PR** - -```bash -git push -u origin garmin-auth-swap -gh pr create --title "Swap Garmin auth from Playwright to python-garminconnect" --body "$(cat <<'EOF' -## Summary -Replaces the Playwright browser login with the browser-free python-garminconnect library (curl_cffi). The library owns login, token refresh, and the body-composition upload; eufy-sync keeps the same-date duplicate check. - -- Removes Playwright/Chromium; adds garminconnect + curl_cffi. -- Deletes the hand-rolled FIT encoder (the library builds the FIT file). -- Terminal MFA prompt for interactive runs; headless runs that need a fresh login fail with "run --reauth" instead of hanging (also fixes the latent headless 401 bug). -- One-time re-login for existing users (old session is treated as absent). - -See docs/superpowers/specs/2026-06-18-garmin-auth-curl-cffi-design.md. - -## Test plan -- [x] pytest green; library mocked, no network -- [ ] Manual: fresh `eufy-sync` login against a real Garmin account (incl. MFA), confirm a weigh-in lands in Garmin Connect -- [ ] Manual: `--headless` with no token fails cleanly with the reauth message -EOF -)" -``` - -- [ ] **Step 5: Watch CI, squash-merge, clean up** - -```bash -gh pr checks --watch -gh api -X PUT /repos/sturimcode/eufy-sync/pulls//merge -f merge_method=squash -gh api -X DELETE /repos/sturimcode/eufy-sync/git/refs/heads/garmin-auth-swap -``` - -> Note: this branch keeps the package version unchanged. Cutting the release (and the PyPI README refresh) is a separate decision after merge. diff --git a/docs/superpowers/plans/2026-06-20-garmin-hybrid-auth.md b/docs/superpowers/plans/2026-06-20-garmin-hybrid-auth.md deleted file mode 100644 index f281f37..0000000 --- a/docs/superpowers/plans/2026-06-20-garmin-hybrid-auth.md +++ /dev/null @@ -1,562 +0,0 @@ -# Garmin Hybrid Auth Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Try the browser-free curl_cffi login first and fall back to the Playwright browser login when it fails, injecting the browser-obtained tokens into the library session so upload is unchanged. - -**Architecture:** All logic lives in `eufy_sync/garmin_auth.py`. Restore the pre-swap `browser_login` + DI-token exchange from git history, add a bridge that builds the library's token blob (`{di_token, di_refresh_token, di_client_id}`) and feeds it to `garmin.client.loads()`, and make `GarminAuth.login()` two-tier: curl_cffi, then browser on any fresh-login failure (interactive only). Chromium installs lazily, only when the fallback fires. - -**Tech Stack:** Python 3.12+, garminconnect, curl_cffi, playwright (lazy Chromium), keyring, pytest. Tests mock `garminconnect.Garmin`, `browser_login`, and `_exchange_ticket_for_tokens`; none open a browser or hit the network. - -**Source spec:** `docs/superpowers/specs/2026-06-20-garmin-hybrid-auth-design.md` - -**Test runner:** `~/.local/bin/uv run pytest` - -## Global Constraints - -- macOS-only tool; Python >=3.12 (do not change the floor). -- No em-dashes in any docs/copy; no marketing filler words. -- The browser fallback is interactive-only; headless runs never open a browser. -- `garmin_client.py`, `sync.py`, `transform.py`, `config.py`, `credentials.py` must not change. - ---- - -## Task 1: Restore browser-login machinery and re-add Playwright - -Bring back the browser login, the DI-token exchange (adapted to return the client id), and the lazy Chromium installer as standalone functions. No wiring into `login()` yet, so the suite stays green. - -**Files:** -- Modify: `eufy_sync/pyproject.toml`, `requirements.txt` -- Modify: `eufy_sync/garmin_auth.py` -- Test: `tests/test_garmin_auth.py` - -**Interfaces:** -- Produces: `browser_login(email: str, password: str) -> str` (service ticket); `_exchange_ticket_for_tokens(service_ticket: str) -> tuple[str, str, str]` returning `(access_token, refresh_token, client_id)`; `_ensure_chromium() -> None`. - -- [ ] **Step 1: Re-add the playwright dependency** - -In `pyproject.toml`, the `dependencies` list currently has `garminconnect`, `curl_cffi`, `httpx`, `keyring`, `pyyaml`. Add `playwright>=1.40.0`: -```toml -dependencies = [ - "httpx>=0.27.0", - "keyring>=25.0.0", - "garminconnect>=0.3.6", - "curl_cffi>=0.7.0", - "playwright>=1.40.0", - "pyyaml>=6.0", -] -``` -In `requirements.txt`, add the `playwright>=1.40.0` line back alongside the others. - -- [ ] **Step 2: Sync env** - -Run: `~/.local/bin/uv run python -c "import playwright; print('playwright ok')"` -Expected: `playwright ok`. - -- [ ] **Step 3: Write the failing test** - -Append to `tests/test_garmin_auth.py`: -```python -def test_exchange_ticket_returns_tokens_and_client_id(): - from eufy_sync.garmin_auth import _exchange_ticket_for_tokens - resp = MagicMock(status_code=200) - resp.json.return_value = {"access_token": "acc", "refresh_token": "ref", "expires_in": 3600} - with patch("eufy_sync.garmin_auth.httpx.post", return_value=resp): - access, refresh, client_id = _exchange_ticket_for_tokens("ticket123") - assert access == "acc" - assert refresh == "ref" - assert client_id # the client id the exchange succeeded with -``` - -- [ ] **Step 4: Run, confirm fail** - -Run: `~/.local/bin/uv run pytest tests/test_garmin_auth.py::test_exchange_ticket_returns_tokens_and_client_id -xvs` -Expected: `ImportError` (the function does not exist yet). - -- [ ] **Step 5: Add imports and constants to `eufy_sync/garmin_auth.py`** - -Add the new stdlib/httpx imports the restored code needs. The current top imports are `json`, `logging`, `os`, `from pathlib import Path`, and `from garminconnect import (Garmin, GarminConnectAuthenticationError, GarminConnectTooManyRequestsError)`. Leave the `garminconnect` import unchanged for now (the existing `_fresh_login` still uses the error classes; Task 2 removes them). Add these so the block becomes: -```python -import base64 -import json -import logging -import os -import subprocess -import sys -from pathlib import Path - -import httpx - -from garminconnect import ( - Garmin, - GarminConnectAuthenticationError, - GarminConnectTooManyRequestsError, -) -``` - -After `logger = logging.getLogger(__name__)`, add the constants and helper: -```python -# Garmin OAuth2 / SSO endpoints (public app identifiers, not per-user secrets) -DI_TOKEN_URL = "https://diauth.garmin.com/di-oauth2-service/oauth/token" -DI_CLIENT_IDS = [ - "GARMIN_CONNECT_MOBILE_ANDROID_DI_2025Q2", - "GARMIN_CONNECT_MOBILE_ANDROID_DI_2024Q4", - "GARMIN_CONNECT_MOBILE_ANDROID_DI", -] -DI_GRANT_TYPE = "https://connectapi.garmin.com/di-oauth2-service/oauth/grant/service_ticket" -SERVICE_URL = "https://mobile.integration.garmin.com/gcm/android" -SSO_LOGIN_URL = ( - "https://sso.garmin.com/mobile/sso/en_US/sign-in" - "?clientId=GCM_ANDROID_DARK" - "&service=https://mobile.integration.garmin.com/gcm/android" -) - - -def _basic_auth_header(client_id: str) -> str: - """Garmin uses Basic auth with client_id as username, empty password.""" - encoded = base64.b64encode(f"{client_id}:".encode()).decode() - return f"Basic {encoded}" -``` - -- [ ] **Step 6: Add `browser_login`, `_exchange_ticket_for_tokens`, `_ensure_chromium`** - -Add these module-level functions (after `_basic_auth_header`). `browser_login` is the verbatim pre-swap implementation: -```python -def browser_login(email: str, password: str) -> str: - """Open browser, log in to Garmin, intercept serviceTicketId. - - Returns the service ticket string. - """ - from playwright.sync_api import sync_playwright - - captured_ticket = [] - - def handle_login_capture(source, result_json): - try: - data = json.loads(result_json) if isinstance(result_json, str) else result_json - if data.get("responseStatus", {}).get("type") == "SUCCESSFUL": - ticket = data.get("serviceTicketId") - if ticket: - captured_ticket.append(ticket) - logger.info("Captured service ticket from login response") - except Exception as e: - logger.warning("Failed to parse login capture: %s", e) - - with sync_playwright() as p: - browser = p.chromium.launch(headless=False) - context = browser.new_context( - user_agent=( - "Mozilla/5.0 (Linux; Android 13; sdk_gphone64_arm64)" - " AppleWebKit/537.36 (KHTML, like Gecko)" - " Chrome/121.0.0.0 Mobile Safari/537.36" - ), - viewport={"width": 412, "height": 915}, - is_mobile=True, - ) - context.expose_binding( - "pirateGarminCaptureLogin", - lambda source, data: handle_login_capture(source, data), - ) - context.add_init_script(""" - (function() { - const originalFetch = window.fetch; - window.fetch = async function(...args) { - const response = await originalFetch.apply(this, args); - const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; - if (url.includes('/mobile/api/login')) { - try { - const clone = response.clone(); - const data = await clone.json(); - window.pirateGarminCaptureLogin(JSON.stringify(data)); - } catch(e) {} - } - return response; - }; - const origOpen = XMLHttpRequest.prototype.open; - const origSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.open = function(method, url, ...rest) { - this._url = url; - return origOpen.call(this, method, url, ...rest); - }; - XMLHttpRequest.prototype.send = function(...args) { - this.addEventListener('load', function() { - if (this._url && this._url.includes('/mobile/api/login')) { - try { - window.pirateGarminCaptureLogin(this.responseText); - } catch(e) {} - } - }); - return origSend.apply(this, args); - }; - })(); - """) - - page = context.new_page() - page.goto(SSO_LOGIN_URL, wait_until="domcontentloaded", timeout=60000) - page.wait_for_selector( - "input[name='username'], input[name='email'], #username, #email", - timeout=30000, - ) - for selector in ["input[name='username']", "input[name='email']", "#username", "#email"]: - try: - el = page.query_selector(selector) - if el and el.is_visible(): - el.fill(email) - break - except Exception: - continue - for selector in ["input[name='password']", "#password"]: - try: - el = page.query_selector(selector) - if el and el.is_visible(): - el.fill(password) - break - except Exception: - continue - for selector in ["button[type='submit']", "#login-btn-signin", "button.btn-primary"]: - try: - el = page.query_selector(selector) - if el and el.is_visible(): - el.click() - break - except Exception: - continue - - logger.info("Waiting for Garmin login to complete (check the browser window)...") - for _ in range(360): - if captured_ticket: - break - page.wait_for_timeout(500) - browser.close() - - if not captured_ticket: - raise RuntimeError( - "Garmin login failed - no service ticket captured. " - "Check your Garmin email/password, and watch for CAPTCHA or MFA prompts." - ) - return captured_ticket[0] - - -def _exchange_ticket_for_tokens(service_ticket: str) -> tuple[str, str, str]: - """Exchange a service ticket for DI tokens. Returns (access, refresh, client_id).""" - for client_id in DI_CLIENT_IDS: - try: - resp = httpx.post( - DI_TOKEN_URL, - headers={ - "authorization": _basic_auth_header(client_id), - "content-type": "application/x-www-form-urlencoded", - }, - data={ - "grant_type": DI_GRANT_TYPE, - "service_ticket": service_ticket, - "service_url": SERVICE_URL, - "client_id": client_id, - }, - timeout=30.0, - ) - if resp.status_code == 200: - data = resp.json() - logger.info("Got DI tokens with client_id: %s", client_id) - return data["access_token"], data["refresh_token"], client_id - logger.debug("Client ID %s returned %d, trying next", client_id, resp.status_code) - except Exception as e: - logger.debug("Client ID %s failed: %s", client_id, e) - raise RuntimeError( - "Failed to exchange Garmin service ticket for tokens. " - "This is usually a temporary Garmin server issue." - ) - - -def _ensure_chromium() -> None: - """Install Playwright Chromium if not already present (lazy, only on fallback).""" - try: - from playwright.sync_api import sync_playwright - with sync_playwright() as p: - path = p.chromium.executable_path - if not Path(path).exists(): - raise FileNotFoundError(path) - except Exception: - print("Installing Chromium for the Garmin browser login (one-time)...") - result = subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"]) - if result.returncode != 0: - from eufy_sync.sync import PermanentSyncError - raise PermanentSyncError( - "Failed to install Chromium for the Garmin browser fallback. " - "Try: playwright install chromium" - ) -``` - -- [ ] **Step 7: Run the test and the suite** - -Run: `~/.local/bin/uv run pytest tests/test_garmin_auth.py -x` -Expected: the new test passes; existing tests still pass. `login()` and the old `_fresh_login` are unchanged in this task, and the `garminconnect` error imports they use are still present (Step 5 kept them), so nothing breaks. - -Then run the full suite: `~/.local/bin/uv run pytest -q` -> green. - -- [ ] **Step 8: Commit** - -```bash -git add pyproject.toml requirements.txt eufy_sync/garmin_auth.py tests/test_garmin_auth.py -git commit -m "feat(garmin): restore browser login + DI exchange as fallback machinery" -``` - ---- - -## Task 2: Two-tier login with browser fallback - -Rework `_fresh_login` so it tries curl_cffi then falls back to the browser, add the token bridge, and update the tests that asserted the old rate-limit-message behavior. - -**Files:** -- Modify: `eufy_sync/garmin_auth.py` -- Test: `tests/test_garmin_auth.py` - -**Interfaces:** -- Consumes: `browser_login`, `_exchange_ticket_for_tokens`, `_ensure_chromium` (Task 1); `garmin.client.loads(json_str)` and `garmin.client.dumps()` (library). -- Produces: `GarminAuth.login(interactive)` and `force_reauth()` with curl_cffi-then-browser behavior; `GarminAuth._browser_fallback(garmin)`. - -- [ ] **Step 1: Update the existing rate-limit/credential tests for the new behavior** - -In `tests/test_garmin_auth.py`, DELETE these three now-obsolete tests (the hybrid no longer raises on a 429; it falls back): `test_login_rate_limited_raises_clear_error`, `test_login_bad_credentials_raises_clear_error`, `test_force_reauth_rate_limited_raises_clear_error`. - -Add these replacements: -```python -def test_login_curl_cffi_success_skips_browser(monkeypatch): - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: None) - monkeypatch.setattr(auth, "_save_token", lambda g: None) - fake = MagicMock() # garmin.login() succeeds (no side_effect) - browser_calls = [] - monkeypatch.setattr("eufy_sync.garmin_auth.browser_login", - lambda e, p: browser_calls.append(1) or "ticket") - with patch("eufy_sync.garmin_auth.Garmin", return_value=fake): - result = auth.login(interactive=True) - fake.login.assert_called_once() - assert browser_calls == [] # browser never opened - assert result is fake - - -def test_login_falls_back_to_browser_when_curl_cffi_fails(monkeypatch): - from garminconnect import GarminConnectTooManyRequestsError - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: None) - monkeypatch.setattr(auth, "_save_token", lambda g: None) - fake = MagicMock() - fake.login.side_effect = GarminConnectTooManyRequestsError("429") - monkeypatch.setattr("eufy_sync.garmin_auth._ensure_chromium", lambda: None) - monkeypatch.setattr("eufy_sync.garmin_auth.browser_login", lambda e, p: "ticket") - monkeypatch.setattr("eufy_sync.garmin_auth._exchange_ticket_for_tokens", - lambda t: ("acc", "ref", "cid")) - with patch("eufy_sync.garmin_auth.Garmin", return_value=fake): - result = auth.login(interactive=True) - fake.client.loads.assert_called_once() - loaded = json.loads(fake.client.loads.call_args.args[0]) - assert loaded == {"di_token": "acc", "di_refresh_token": "ref", "di_client_id": "cid"} - assert result is fake - - -def test_login_bad_credentials_fails_both_tiers(monkeypatch): - from garminconnect import GarminConnectAuthenticationError - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: None) - fake = MagicMock() - fake.login.side_effect = GarminConnectAuthenticationError("bad") - monkeypatch.setattr("eufy_sync.garmin_auth._ensure_chromium", lambda: None) - - def _no_ticket(email, password): - raise RuntimeError("no service ticket captured") - monkeypatch.setattr("eufy_sync.garmin_auth.browser_login", _no_ticket) - with patch("eufy_sync.garmin_auth.Garmin", return_value=fake): - with pytest.raises(PermanentSyncError) as exc_info: - auth.login(interactive=True) - assert "update-password" in str(exc_info.value) - - -def test_login_headless_never_opens_browser(monkeypatch): - auth = _auth() - monkeypatch.setattr(auth, "_load_token", lambda: None) - browser_calls = [] - monkeypatch.setattr("eufy_sync.garmin_auth.browser_login", - lambda e, p: browser_calls.append(1) or "ticket") - with patch("eufy_sync.garmin_auth.Garmin", return_value=MagicMock()): - with pytest.raises(PermanentSyncError): - auth.login(interactive=False) - assert browser_calls == [] - - -def test_force_reauth_falls_back_to_browser(monkeypatch): - from garminconnect import GarminConnectTooManyRequestsError - auth = _auth() - monkeypatch.setattr(auth, "_clear_token", lambda: None) - monkeypatch.setattr(auth, "_save_token", lambda g: None) - fake = MagicMock() - fake.login.side_effect = GarminConnectTooManyRequestsError("429") - monkeypatch.setattr("eufy_sync.garmin_auth._ensure_chromium", lambda: None) - monkeypatch.setattr("eufy_sync.garmin_auth.browser_login", lambda e, p: "ticket") - monkeypatch.setattr("eufy_sync.garmin_auth._exchange_ticket_for_tokens", - lambda t: ("a", "r", "c")) - with patch("eufy_sync.garmin_auth.Garmin", return_value=fake): - result = auth.force_reauth() - fake.client.loads.assert_called_once() - assert result is fake -``` - -- [ ] **Step 2: Run, confirm the new fallback test fails** - -Run: `~/.local/bin/uv run pytest tests/test_garmin_auth.py::test_login_falls_back_to_browser_when_curl_cffi_fails -xvs` -Expected: FAIL (current `_fresh_login` raises `PermanentSyncError` on 429 instead of falling back). - -- [ ] **Step 3: Replace `_fresh_login` and add `_browser_fallback`** - -In `eufy_sync/garmin_auth.py`, replace the entire `_fresh_login` method with: -```python - def _fresh_login(self, garmin: Garmin) -> None: - """Try the browser-free login; fall back to the browser on any failure.""" - try: - garmin.login() - return - except Exception as e: - logger.warning( - "Browser-free Garmin login failed (%s); opening browser fallback", e - ) - self._browser_fallback(garmin) - - def _browser_fallback(self, garmin: Garmin) -> None: - """Log in via the Playwright browser and inject the tokens into the session.""" - from eufy_sync.sync import PermanentSyncError - _ensure_chromium() - try: - ticket = browser_login(self.email, self.password) - except Exception as e: - raise PermanentSyncError( - "Garmin login failed. If you changed your password, run: " - "eufy-sync --update-password. Otherwise re-run: eufy-sync --reauth" - ) from e - try: - access, refresh, client_id = _exchange_ticket_for_tokens(ticket) - except Exception as e: - raise PermanentSyncError( - "Garmin token exchange failed; try: eufy-sync --reauth" - ) from e - blob = {"di_token": access, "di_refresh_token": refresh, "di_client_id": client_id} - garmin.client.loads(json.dumps(blob)) -``` - -The rewritten `_fresh_login` no longer references the `garminconnect` error classes, so simplify the import at the top of the file from `from garminconnect import (Garmin, GarminConnectAuthenticationError, GarminConnectTooManyRequestsError)` to just `from garminconnect import Garmin`. (`sync.py` keeps its own `GarminConnectTooManyRequestsError` import; that is unaffected.) - -- [ ] **Step 4: Run the auth tests** - -Run: `~/.local/bin/uv run pytest tests/test_garmin_auth.py -x` -Expected: all pass (the restore test, the new fallback/success/headless/bad-creds/force_reauth tests, and the unchanged `_load_token`/`token_status` tests). - -- [ ] **Step 5: Run the full suite** - -Run: `~/.local/bin/uv run pytest -q` -Expected: green. (`sync._is_permanent` 429 rule and `test_sync.py::test_rate_limit_error_is_permanent` are unchanged and still pass; they cover the mid-sync refresh case.) - -- [ ] **Step 6: Confirm no import cycle and CLI still imports** - -Run: `~/.local/bin/uv run python -c "import eufy_sync.garmin_auth, eufy_sync.cli, eufy_sync.sync"` -Expected: no error. - -- [ ] **Step 7: Commit** - -```bash -git add eufy_sync/garmin_auth.py tests/test_garmin_auth.py -git commit -m "feat(garmin): two-tier login - curl_cffi first, browser fallback" -``` - ---- - -## Task 3: Wizard text and README - -**Files:** -- Modify: `eufy_sync/cli.py` -- Modify: `README.md` - -- [ ] **Step 1: Update the first-run wizard line** - -In `eufy_sync/cli.py`, in `_first_run_setup`, find the line that prints the first-sync message (it reads `print(f"Saved. Running first sync to {' and '.join(targets)} (last 7 days)...")`). Immediately after it, add a Garmin note: -```python - if garmin_email: - print("Logging in to Garmin (a browser may open if the direct login is rate-limited).") -``` -(`garmin_email` is in scope in `_first_run_setup`. Confirm with `grep -n "garmin_email" eufy_sync/cli.py`.) - -- [ ] **Step 2: Rewrite the README "How Garmin login works" section** - -Writing rules: no em-dashes; no marketing filler; plain prose; sentence case. - -Replace the current section body with: -``` -## How Garmin login works - -Garmin has no official API for writing body composition into Connect. In March 2026 it put Cloudflare in front of its login, which broke the Python libraries that talked to it; [garth](https://github.com/matin/garth) was [deprecated](https://github.com/matin/garth/discussions/222) and stays that way. - -eufy-sync logs in through [python-garminconnect](https://github.com/cyberjunky/python-garminconnect), which gets past Cloudflare without a browser. On first run you enter your Garmin email and password in the terminal, and a code if your account uses two-factor. If that direct login is rate-limited or temporarily blocked, eufy-sync falls back to a one-time browser login: a Chromium window opens, you sign in, and it continues. Either way the tokens are saved to your system keychain and refresh on their own, so later runs need no login. Headless setups (a server or scheduled job) use the direct login only, since there is no screen for a browser. -``` - -- [ ] **Step 3: Run the suite and a smoke check** - -Run: `~/.local/bin/uv run pytest -q` -> green. -Run: `~/.local/bin/uv run eufy-sync --help` -> prints without error. - -- [ ] **Step 4: Commit** - -```bash -git add eufy_sync/cli.py README.md -git commit -m "docs: describe the hybrid Garmin login (browser fallback)" -``` - ---- - -## Final Verification - -- [ ] **Step 1: Full suite** - -Run: `~/.local/bin/uv run pytest -v` -> all pass. - -- [ ] **Step 2: Confirm the pieces are wired** - -Run: -```bash -~/.local/bin/uv run python -c "from eufy_sync.garmin_auth import browser_login, _exchange_ticket_for_tokens, _ensure_chromium; print('fallback machinery present')" -grep -n "playwright" pyproject.toml requirements.txt -``` -Expected: prints the confirmation; playwright is in both dependency files. - -- [ ] **Step 3: Push and open a PR** - -```bash -git push -u origin garmin-hybrid-auth -gh pr create --title "Hybrid Garmin auth: curl_cffi primary, browser fallback" --body "$(cat <<'EOF' -## Summary -Makes Garmin login resilient: try the browser-free curl_cffi login first, fall back to the Playwright browser login on any fresh-login failure (interactive only). Browser-obtained DI tokens are injected into the library session via client.loads(), so upload is unchanged and the browser path sidesteps the library's open #369 validation bug. - -- Two-tier GarminAuth.login() and force_reauth(). -- Restores browser_login + DI exchange from git history; adds the token bridge. -- Chromium installs lazily, only when the fallback fires. -- Headless stays curl_cffi-only and fails with a reauth message. -- Re-adds playwright as a dependency. - -See docs/superpowers/specs/2026-06-20-garmin-hybrid-auth-design.md. - -## Test plan -- [x] pytest green; both login paths mocked, no browser or network -- [ ] Manual: force a curl_cffi failure (or rate limit) and confirm the browser fallback completes and a weigh-in lands in Garmin -- [ ] Manual: a normal login uses curl_cffi with no browser - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -- [ ] **Step 4: Watch CI, squash-merge, clean up** - -```bash -gh pr checks --watch -gh api -X PUT /repos/sturimcode/eufy-sync/pulls//merge -f merge_method=squash -gh api -X DELETE /repos/sturimcode/eufy-sync/git/refs/heads/garmin-hybrid-auth -``` - -> Version stays at 1.7.3 on this branch. Cutting 1.7.4 (which ships the swap, the 429 handling, the hybrid, and the doc fixes together) is a separate step after this merges and you confirm the fallback works live. diff --git a/docs/superpowers/plans/2026-06-27-inline-profile-picker.md b/docs/superpowers/plans/2026-06-27-inline-profile-picker.md deleted file mode 100644 index 8c5279c..0000000 --- a/docs/superpowers/plans/2026-06-27-inline-profile-picker.md +++ /dev/null @@ -1,573 +0,0 @@ -# Inline Profile Picker and Quieter Garmin 429 Output Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Resolve a multi-profile Eufy sync in place during an interactive run, and stop a successful Garmin login from printing alarming 429 warnings. - -**Architecture:** Two independent changes in `eufy_sync/cli.py`. Part 1 catches `AmbiguousProfileError` in the sync loop and, when a human is present, prompts with the existing picker, saves the choice, and re-runs the sync in the same process. Part 2 extracts logging setup into a helper that quiets the chatty `garminconnect` library logger on normal runs. Both are covered by tests in `tests/test_cli.py`. - -**Tech Stack:** Python 3.12, pytest, python-garminconnect, PyYAML. - -## Global Constraints - -- No em-dashes in any copy, UI text, or documentation. Copy this verbatim into any user-facing string. -- One user per installation. `load_config` raises on more than one user, so the selected profile always belongs to `config.users[0]`. -- Run tests with `.venv/bin/python -m pytest`. Baseline before this plan: 110 passed. -- Follow the codebase habit of local imports inside functions where the surrounding code already does so. -- TDD: write the failing test first, watch it fail, implement the minimum, watch it pass, commit. - -## File Structure - -- `eufy_sync/cli.py` (modify): add `_configure_logging(verbose)` and `_save_customer_id(config_path, customer_id)` helpers; refactor `_select_profile` to use the new save helper; replace the inline logging block and add a logging call to the reauth dispatch; rewrite the `except AmbiguousProfileError` block in the sync loop; add a dedicated notification branch for the multi-profile failure. -- `tests/test_cli.py` (modify): add `import pytest` and four new tests. - ---- - -### Task 1: Quiet the Garmin 429 retry noise - -**Files:** -- Modify: `eufy_sync/cli.py` (new `_configure_logging`; replace inline logging block at lines 951-957; add a call in the reauth dispatch near line 908) -- Test: `tests/test_cli.py` - -**Interfaces:** -- Produces: `_configure_logging(verbose: bool) -> None`. Sets the root log level (DEBUG when verbose, WARNING otherwise), quiets httpx on normal runs, and sets the `garminconnect` logger to ERROR on normal runs / DEBUG when verbose. - -- [ ] **Step 1: Write the failing tests** - -Add to the end of `tests/test_cli.py`: - -```python -def test_configure_logging_quiets_garminconnect_when_not_verbose(): - import logging - from eufy_sync.cli import _configure_logging - - logging.getLogger("garminconnect").setLevel(logging.NOTSET) - _configure_logging(verbose=False) - assert logging.getLogger("garminconnect").level == logging.ERROR - - -def test_configure_logging_keeps_garminconnect_detail_when_verbose(): - import logging - from eufy_sync.cli import _configure_logging - - logging.getLogger("garminconnect").setLevel(logging.ERROR) - _configure_logging(verbose=True) - assert logging.getLogger("garminconnect").level == logging.DEBUG -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv/bin/python -m pytest tests/test_cli.py::test_configure_logging_quiets_garminconnect_when_not_verbose tests/test_cli.py::test_configure_logging_keeps_garminconnect_detail_when_verbose -v` -Expected: FAIL with `ImportError` / `cannot import name '_configure_logging'`. - -- [ ] **Step 3: Implement `_configure_logging`** - -In `eufy_sync/cli.py`, add this helper near the other module-level helpers (for example just above `def _format_profile`): - -```python -def _configure_logging(verbose: bool) -> None: - """Set up logging. On normal runs, quiet chatty library loggers; under - --verbose, show full detail. The garminconnect library logs a warning for - each login strategy that hits a 429 before a later strategy succeeds, which - looks alarming on an otherwise successful login.""" - import logging - fmt = "%(asctime)s %(levelname)s %(name)s: %(message)s" - if verbose: - logging.basicConfig(level=logging.DEBUG, format=fmt) - logging.getLogger("garminconnect").setLevel(logging.DEBUG) - else: - logging.basicConfig(level=logging.WARNING, format=fmt) - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("garminconnect").setLevel(logging.ERROR) -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `.venv/bin/python -m pytest tests/test_cli.py::test_configure_logging_quiets_garminconnect_when_not_verbose tests/test_cli.py::test_configure_logging_keeps_garminconnect_detail_when_verbose -v` -Expected: PASS (2 passed). - -- [ ] **Step 5: Wire the helper into the sync path** - -In `eufy_sync/cli.py`, replace the inline logging block in the sync path. The current code is: - -```python - log_level = "DEBUG" if args.verbose else "WARNING" - logging.basicConfig( - level=getattr(logging, log_level), - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - ) - if not args.verbose: - logging.getLogger("httpx").setLevel(logging.WARNING) - logger = logging.getLogger("eufy_sync") -``` - -Replace it with: - -```python - _configure_logging(args.verbose) - logger = logging.getLogger("eufy_sync") -``` - -Leave the `import logging` line above it in place (it is still used for `getLogger`). - -- [ ] **Step 6: Wire the helper into the reauth dispatch** - -In `eufy_sync/cli.py`, the reauth dispatch currently reads: - -```python - # Handle reauth - if args.reauth is not None: - target = None if args.reauth == "all" else args.reauth - _reauth(config_path, force=True, target=target) - return -``` - -Change it to call the logging helper first so the reauth login is quiet too: - -```python - # Handle reauth - if args.reauth is not None: - _configure_logging(args.verbose) - target = None if args.reauth == "all" else args.reauth - _reauth(config_path, force=True, target=target) - return -``` - -- [ ] **Step 7: Run the full suite to confirm nothing regressed** - -Run: `.venv/bin/python -m pytest -q` -Expected: PASS (112 passed: 110 baseline + 2 new). - -- [ ] **Step 8: Commit** - -```bash -git add eufy_sync/cli.py tests/test_cli.py -git commit -m "$(cat <<'EOF' -Quiet garminconnect 429 retry noise on successful login - -Extract logging setup into _configure_logging and raise the garminconnect -logger to ERROR on normal runs. The library logs a warning per login strategy -that hits a 429 before a later strategy succeeds; those attempts do not change -the outcome and only show up because the run sets the log floor to WARNING. ---verbose still shows full detail. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -### Task 2: Extract `_save_customer_id` and reuse it in `_select_profile` - -**Files:** -- Modify: `eufy_sync/cli.py` (new `_save_customer_id`; refactor `_select_profile` lines 296-333) -- Test: `tests/test_cli.py` - -**Interfaces:** -- Produces: `_save_customer_id(config_path: Path, customer_id: str) -> None`. Reads the YAML config, sets `users[0]["eufy"]["customer_id"]`, and writes it back with `_write_config`. -- Consumes: `_write_config` (existing). - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_cli.py`: - -```python -def test_save_customer_id_writes_into_config(tmp_path: Path): - from eufy_sync.cli import _save_customer_id, _write_config - - cfg_path = tmp_path / "config.yaml" - _write_config(cfg_path, { - "users": [{ - "name": "default", - "eufy": {"email": "e@example.com", "password": "pw"}, - "garmin": {"email": "g@example.com", "password": "pw"}, - }], - }) - - _save_customer_id(cfg_path, "cid-xyz") - - written = yaml.safe_load(cfg_path.read_text()) - assert written["users"][0]["eufy"]["customer_id"] == "cid-xyz" - # Existing fields are left intact. - assert written["users"][0]["eufy"]["email"] == "e@example.com" - assert written["users"][0]["name"] == "default" -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `.venv/bin/python -m pytest tests/test_cli.py::test_save_customer_id_writes_into_config -v` -Expected: FAIL with `cannot import name '_save_customer_id'`. - -- [ ] **Step 3: Implement `_save_customer_id`** - -In `eufy_sync/cli.py`, add the helper just above `def _select_profile`: - -```python -def _save_customer_id(config_path: Path, customer_id: str) -> None: - """Persist the chosen Eufy customer_id into the config file (single user).""" - with open(config_path) as f: - config = yaml.safe_load(f) - user = config["users"][0] - user.setdefault("eufy", {}) - user["eufy"]["customer_id"] = customer_id - _write_config(config_path, config) -``` - -- [ ] **Step 4: Refactor `_select_profile` to use the helper** - -In `eufy_sync/cli.py`, the tail of `_select_profile` currently reads: - -```python - with open(config_path) as f: - config = yaml.safe_load(f) - - from eufy_sync.config import load_config - from eufy_sync.eufy_client import EufyClient - - cfg = load_config(config_path) - eufy = EufyClient(cfg.users[0].eufy) - try: - eufy.authenticate() - profiles = eufy.list_profiles() - except Exception as e: - print(f"Could not reach Eufy: {e}") - sys.exit(1) - finally: - eufy.close() - - if not profiles: - print("No profiles found yet. Weigh in and open the Eufy app, then try again.") - return - - user = config["users"][0] - user.setdefault("eufy", {}) - if len(profiles) == 1: - user["eufy"]["customer_id"] = profiles[0].customer_id - _write_config(config_path, config) - print("Only one profile found on this account; saved it as yours.") - return - - user["eufy"]["customer_id"] = _prompt_profile_choice(profiles) - _write_config(config_path, config) - print("Saved. Future syncs will use only your profile.") -``` - -Replace it with (the raw YAML read at the top and the in-line config mutation are no longer needed): - -```python - from eufy_sync.config import load_config - from eufy_sync.eufy_client import EufyClient - - cfg = load_config(config_path) - eufy = EufyClient(cfg.users[0].eufy) - try: - eufy.authenticate() - profiles = eufy.list_profiles() - except Exception as e: - print(f"Could not reach Eufy: {e}") - sys.exit(1) - finally: - eufy.close() - - if not profiles: - print("No profiles found yet. Weigh in and open the Eufy app, then try again.") - return - - if len(profiles) == 1: - _save_customer_id(config_path, profiles[0].customer_id) - print("Only one profile found on this account; saved it as yours.") - return - - _save_customer_id(config_path, _prompt_profile_choice(profiles)) - print("Saved. Future syncs will use only your profile.") -``` - -- [ ] **Step 5: Run the new test and the existing select-profile test** - -Run: `.venv/bin/python -m pytest tests/test_cli.py::test_save_customer_id_writes_into_config tests/test_cli.py::test_select_profile_writes_chosen_customer_id -v` -Expected: PASS (2 passed). The existing `test_select_profile_writes_chosen_customer_id` still passes because the written `customer_id` is unchanged. - -- [ ] **Step 6: Commit** - -```bash -git add eufy_sync/cli.py tests/test_cli.py -git commit -m "$(cat <<'EOF' -Extract _save_customer_id and reuse it in _select_profile - -Single place that writes the chosen Eufy customer_id into the config, so the -upcoming inline picker and the existing --select-profile flow share one writer. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -### Task 3: Inline profile picker in the sync loop - -**Files:** -- Modify: `eufy_sync/cli.py` (rewrite the `except AmbiguousProfileError` block in the sync loop around lines 977-984; add a `multiple_profiles` branch in the failure-notification block around lines 991-1001) -- Test: `tests/test_cli.py` (add `import pytest`; two integration tests) - -**Interfaces:** -- Consumes: `_prompt_profile_choice` (existing), `_save_customer_id` (Task 2), `_format_profile` (existing), `sync_user` (from `eufy_sync.sync`), `AmbiguousProfileError` (from `eufy_sync.eufy_client`). - -- [ ] **Step 1: Add the pytest import** - -At the top of `tests/test_cli.py`, add `import pytest` to the imports (after `import os`): - -```python -import os -from pathlib import Path -from unittest.mock import patch, MagicMock - -import pytest -import yaml -``` - -- [ ] **Step 2: Write the failing tests** - -Add to `tests/test_cli.py`: - -```python -def _ambiguous_profiles(): - from datetime import datetime, timezone - from eufy_sync.eufy_client import EufyProfile - return [ - EufyProfile("cid-human", datetime(2026, 6, 27, tzinfo=timezone.utc), 88.0), - EufyProfile("cid-pet", datetime(2026, 4, 4, tzinfo=timezone.utc), 4.5), - ] - - -def _write_synced_config(tmp_path: Path): - config_path = tmp_path / "config.yaml" - _write_config(config_path, { - "users": [{ - "name": "default", - "eufy": {"email": "e@example.com", "password": "pw"}, - "garmin": {"email": "g@example.com", "password": "pw"}, - }], - }) - return config_path - - -@patch("eufy_sync.cli._print_summary") -@patch("eufy_sync.cli._notify") -@patch("eufy_sync.cli._check_for_updates") -@patch("eufy_sync.cli._show_upgrade_notice") -@patch("eufy_sync.cli._migrate_config_passwords") -@patch("eufy_sync.credentials._keyring_available", return_value=False) -@patch("eufy_sync.cli.sys.stdin") -def test_interactive_ambiguous_profile_resolves_and_syncs( - mock_stdin, _keyring, _migrate, _notice, _updates, _notify, _summary, tmp_path -): - from eufy_sync.cli import main - from eufy_sync.eufy_client import AmbiguousProfileError - - mock_stdin.isatty.return_value = True - config_path = _write_synced_config(tmp_path) - db_path = tmp_path / "state.db" - - profiles = _ambiguous_profiles() - seen_customer_ids = [] - - def fake_sync_user(user, state, **kwargs): - seen_customer_ids.append(user.eufy.customer_id) - if len(seen_customer_ids) == 1: - raise AmbiguousProfileError(profiles) - return {"garmin": 1} - - argv = ["eufy-sync", "--config", str(config_path), "--db", str(db_path)] - with patch("eufy_sync.sync.sync_user", side_effect=fake_sync_user), \ - patch("sys.argv", argv), \ - patch("builtins.input", return_value="1"), \ - pytest.raises(SystemExit) as exc: - main() - - assert exc.value.code == 0 - # The chosen (human) profile was persisted to config. - written = yaml.safe_load(config_path.read_text()) - assert written["users"][0]["eufy"]["customer_id"] == "cid-human" - # The sync was retried in-process with that customer_id set in memory. - assert seen_customer_ids == [None, "cid-human"] - - -@patch("eufy_sync.cli._print_summary") -@patch("eufy_sync.cli._notify") -@patch("eufy_sync.cli._check_for_updates") -@patch("eufy_sync.cli._show_upgrade_notice") -@patch("eufy_sync.cli._migrate_config_passwords") -@patch("eufy_sync.credentials._keyring_available", return_value=False) -@patch("eufy_sync.cli.sys.stdin") -def test_noninteractive_ambiguous_profile_bails( - mock_stdin, _keyring, _migrate, _notice, _updates, _notify, _summary, tmp_path, capsys -): - from eufy_sync.cli import main - from eufy_sync.eufy_client import AmbiguousProfileError - - mock_stdin.isatty.return_value = False # no human present - config_path = _write_synced_config(tmp_path) - db_path = tmp_path / "state.db" - - profiles = _ambiguous_profiles() - - def fake_sync_user(user, state, **kwargs): - raise AmbiguousProfileError(profiles) - - def boom_input(*a, **k): - raise AssertionError("input() must not be called with no TTY present") - - argv = ["eufy-sync", "--config", str(config_path), "--db", str(db_path)] - with patch("eufy_sync.sync.sync_user", side_effect=fake_sync_user), \ - patch("sys.argv", argv), \ - patch("builtins.input", side_effect=boom_input), \ - pytest.raises(SystemExit) as exc: - main() - - assert exc.value.code == 1 - out = capsys.readouterr().out - assert "eufy-sync --select-profile" in out - _notify.assert_any_call( - "eufy-sync: choose your profile", "Run: eufy-sync --select-profile" - ) -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `.venv/bin/python -m pytest tests/test_cli.py::test_interactive_ambiguous_profile_resolves_and_syncs tests/test_cli.py::test_noninteractive_ambiguous_profile_bails -v` -Expected: FAIL. The interactive test fails because `seen_customer_ids` ends as `[None]` (no retry yet) and no customer_id is written. The non-interactive test fails on the missing `_notify` call for the dedicated message. - -- [ ] **Step 4: Rewrite the `except AmbiguousProfileError` block** - -In `eufy_sync/cli.py`, the sync loop currently has: - -```python - except AmbiguousProfileError as e: - print("") - print("Multiple profiles were found on this Eufy account:") - for i, p in enumerate(e.profiles, 1): - print(_format_profile(p, i)) - print("") - print("Nothing was synced. Choose your profile with: eufy-sync --select-profile") - failures.append((user.name, "multiple Eufy profiles; run eufy-sync --select-profile")) -``` - -Replace it with: - -```python - except AmbiguousProfileError as e: - interactive = not args.headless and sys.stdin.isatty() - if interactive: - # _prompt_profile_choice prints the list and asks for a pick. - customer_id = _prompt_profile_choice(e.profiles) - _save_customer_id(config_path, customer_id) - user.eufy.customer_id = customer_id - print("Saved. Syncing your profile now...") - try: - counts = sync_user(user, state, backfill_days=backfill, headless=args.headless, dry_run=args.dry_run) - for target_name, count in counts.items(): - total_counts[target_name] = total_counts.get(target_name, 0) + count - logger.info("User %s: synced %s", user.name, counts) - except Exception as retry_error: - logger.exception("Failed to sync user %s after profile selection", user.name) - failures.append((user.name, str(retry_error))) - else: - print("") - print("Multiple profiles were found on this Eufy account:") - for i, p in enumerate(e.profiles, 1): - print(_format_profile(p, i)) - print("") - print("Nothing was synced. Choose your profile with: eufy-sync --select-profile") - failures.append((user.name, "multiple Eufy profiles; run eufy-sync --select-profile")) -``` - -- [ ] **Step 5: Add the dedicated notification branch** - -In `eufy_sync/cli.py`, the failure-notification block currently reads: - -```python - if failures: - reauth_needed = any("re-authenticate" in err for _, err in failures) - eufy_password = any("changed your Eufy password" in err for _, err in failures) - if reauth_needed: - _notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth") - elif eufy_password: - _notify("eufy-sync: Eufy login failed", "Run: eufy-sync --update-password") - else: - fail_msg = "; ".join(f"{name}: {err[:80]}" for name, err in failures) - _notify("eufy-sync failed", fail_msg) - logger.error("Sync failed for: %s", "; ".join(f"{n}: {e[:80]}" for n, e in failures)) -``` - -Add a `multiple_profiles` branch: - -```python - if failures: - reauth_needed = any("re-authenticate" in err for _, err in failures) - eufy_password = any("changed your Eufy password" in err for _, err in failures) - multiple_profiles = any("multiple Eufy profiles" in err for _, err in failures) - if reauth_needed: - _notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth") - elif eufy_password: - _notify("eufy-sync: Eufy login failed", "Run: eufy-sync --update-password") - elif multiple_profiles: - _notify("eufy-sync: choose your profile", "Run: eufy-sync --select-profile") - else: - fail_msg = "; ".join(f"{name}: {err[:80]}" for name, err in failures) - _notify("eufy-sync failed", fail_msg) - logger.error("Sync failed for: %s", "; ".join(f"{n}: {e[:80]}" for n, e in failures)) -``` - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `.venv/bin/python -m pytest tests/test_cli.py::test_interactive_ambiguous_profile_resolves_and_syncs tests/test_cli.py::test_noninteractive_ambiguous_profile_bails -v` -Expected: PASS (2 passed). - -- [ ] **Step 7: Commit** - -```bash -git add eufy_sync/cli.py tests/test_cli.py -git commit -m "$(cat <<'EOF' -Resolve multi-profile sync inline instead of dead-ending - -When a sync hits multiple Eufy profiles and a human is present (a TTY and not ---headless), prompt with the existing picker, save the choice, and finish the -sync in the same run. Headless and non-TTY runs keep the safe bail and now fire -a dedicated "choose your profile" notification. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -### Task 4: Full-suite verification - -**Files:** none (verification only) - -- [ ] **Step 1: Run the entire test suite** - -Run: `.venv/bin/python -m pytest -q` -Expected: PASS (114 passed: 110 baseline + 4 new). - -- [ ] **Step 2: Confirm the working tree is clean and on the branch** - -Run: `git status --short && git branch --show-current` -Expected: no uncommitted source changes (an untracked `uv.lock` is fine); branch `inline-profile-picker`. - ---- - -## Self-Review - -**Spec coverage:** -- Part 1 inline picker → Task 3. Interactivity gate `not args.headless and sys.stdin.isatty()` → Task 3 Step 4. Persist choice → Task 2 + Task 3. Retry in same run → Task 3 Step 4. Headless bail + dedicated notification → Task 3 Steps 4 and 5. -- Part 2 quiet 429 → Task 1. `_configure_logging` extraction → Task 1 Steps 3, 5, 6. -- Single-user invariant → relied on in `_save_customer_id` (Task 2) and the loop. -- Tests 1-4 from the spec → Task 2 Step 1, Task 3 Step 2 (two tests), Task 1 Step 1 (two tests). - -**Placeholder scan:** none. Every code and command step is concrete. - -**Type consistency:** `_configure_logging(verbose: bool)`, `_save_customer_id(config_path: Path, customer_id: str)`, and `_prompt_profile_choice(profiles) -> str` are used with the same names and signatures across tasks. `sync_user(user, state, backfill_days=, headless=, dry_run=)` matches the call in both the original loop and the inline retry. diff --git a/docs/superpowers/plans/2026-06-27-raw-wifi-weight-fallback.md b/docs/superpowers/plans/2026-06-27-raw-wifi-weight-fallback.md deleted file mode 100644 index 5a6cdb0..0000000 --- a/docs/superpowers/plans/2026-06-27-raw-wifi-weight-fallback.md +++ /dev/null @@ -1,377 +0,0 @@ -# Raw Wi-Fi Weight Fallback Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** When the normal Eufy pull returns nothing for the window, recover the weight from the per-device raw Wi-Fi endpoint so a headless run still syncs. - -**Architecture:** Add a fallback inside `EufyClient.fetch_measurements`: when the existing normal path yields an empty list, fetch raw records per device and run them through the same parser, profile filter, and window filter. Two new HTTP leaf methods plus one orchestration method, all in `eufy_sync/eufy_client.py`. - -**Tech Stack:** Python 3.12, httpx, pytest. - -## Global Constraints - -- No em-dashes in any copy, UI text, or documentation string. -- One user per installation; the configured profile is `config.customer_id`. -- Run tests with `.venv/bin/python -m pytest`. Baseline before this plan: 115 passed. -- The raw endpoint returns records under a `list` field that can be JSON `null`; `res_code == 1` means success. Weight is decigrams (divide by 10 for kg). -- Reuse the existing `_parse_all`, `transform`, and the `m.customer_id == config.customer_id` filter. Add no config options and no new files. -- Follow the existing test style in `tests/test_eufy_client.py` (the `_client()` and `_record()` helpers, `patch.object` for mocking). - -## File Structure - -- `eufy_sync/eufy_client.py` (modify): three new methods (`_list_device_ids`, `_get_raw_records`, `_fetch_raw_measurements`) and a restructured `fetch_measurements` that funnels both branches to a single fallback check. -- `tests/test_eufy_client.py` (modify): a small `_resp()` mock helper and the new tests. - ---- - -### Task 1: HTTP leaf methods (`_list_device_ids`, `_get_raw_records`) - -**Files:** -- Modify: `eufy_sync/eufy_client.py` (add two methods near `_get_records`) -- Test: `tests/test_eufy_client.py` - -**Interfaces:** -- Produces: `_list_device_ids(self) -> list[str]` and `_get_raw_records(self, device_id: str, after_timestamp: int | None) -> list[dict]`. Both read `self._client`, `self.access_token`, `self.user_id`. Both return `[]` on a non-200 status or `res_code != 1`. - -- [ ] **Step 1: Add a response-mock helper and the failing tests** - -In `tests/test_eufy_client.py`, add `MagicMock` to the mock import at the top so it reads: - -```python -from unittest.mock import MagicMock, patch -``` - -Then append: - -```python -def _resp(status_code, json_body): - r = MagicMock() - r.status_code = status_code - r.json.return_value = json_body - return r - - -def test_list_device_ids_parses_device_v2(): - c = _client() - c._client = MagicMock() - c._client.get.return_value = _resp( - 200, {"res_code": 1, "devices": [{"id": "dev1"}, {"id": "dev2"}, {"id": ""}]} - ) - assert c._list_device_ids() == ["dev1", "dev2"] - - -def test_list_device_ids_empty_on_error_code(): - c = _client() - c._client = MagicMock() - c._client.get.return_value = _resp(200, {"res_code": 0, "devices": []}) - assert c._list_device_ids() == [] - - -def test_get_raw_records_extracts_list(): - c = _client() - c._client = MagicMock() - c._client.get.return_value = _resp(200, {"res_code": 1, "list": [_record("a", 800, 100)]}) - recs = c._get_raw_records("dev1", None) - assert len(recs) == 1 - assert recs[0]["customer_id"] == "a" - - -def test_get_raw_records_handles_null_list_500_and_bad_code(): - c = _client() - c._client = MagicMock() - c._client.get.return_value = _resp(200, {"res_code": 1, "list": None}) - assert c._get_raw_records("d", None) == [] - c._client.get.return_value = _resp(500, {}) - assert c._get_raw_records("d", None) == [] - c._client.get.return_value = _resp(200, {"res_code": 500, "message": "unavailable"}) - assert c._get_raw_records("d", None) == [] -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py -k "device_ids or raw_records" -v` -Expected: FAIL with `AttributeError: ... object has no attribute '_list_device_ids'` (and the same for `_get_raw_records`). - -- [ ] **Step 3: Implement the two methods** - -In `eufy_sync/eufy_client.py`, add these methods to `EufyClient`, just after `_get_records` (around line 222): - -```python - def _list_device_ids(self) -> list[str]: - """Return the account's device ids from /device/v2. Empty on any error.""" - resp = self._client.get( - f"{BASE_URL}/device/v2", - headers={"Token": self.access_token, "Uid": self.user_id}, - ) - if resp.status_code != 200: - return [] - body = resp.json() - if body.get("res_code") != 1: - return [] - return [d["id"] for d in body.get("devices", []) if d.get("id")] - - def _get_raw_records(self, device_id: str, after_timestamp: int | None) -> list[dict]: - """Fetch a device's raw Wi-Fi weight records. These appear before the - phone app processes a weigh-in. Records live under a nullable `list` - field. Empty on any error.""" - params = {} - if after_timestamp is not None: - params["after"] = str(after_timestamp) - resp = self._client.get( - f"{BASE_URL}/device/wifi_scale/raw_data/{device_id}", - params=params, - headers={"Token": self.access_token, "Uid": self.user_id}, - ) - if resp.status_code != 200: - return [] - body = resp.json() - if body.get("res_code") != 1: - return [] - return body.get("list") or [] -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py -k "device_ids or raw_records" -v` -Expected: PASS (4 passed). - -- [ ] **Step 5: Run the full file to confirm no regression** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py -q` -Expected: PASS (all existing tests plus the 4 new ones). - -- [ ] **Step 6: Commit** - -```bash -git add eufy_sync/eufy_client.py tests/test_eufy_client.py -git commit -m "$(cat <<'EOF' -Add raw Wi-Fi endpoint HTTP helpers to EufyClient - -_list_device_ids reads /device/v2; _get_raw_records reads the per-device -wifi_scale/raw_data endpoint (records under a nullable `list` field). Both -degrade to an empty list on a non-200 status or res_code != 1. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -### Task 2: Fallback orchestration and the `fetch_measurements` trigger - -**Files:** -- Modify: `eufy_sync/eufy_client.py` (rewrite `fetch_measurements` at lines 254-269; add `_fetch_raw_measurements`) -- Test: `tests/test_eufy_client.py` - -**Interfaces:** -- Consumes: `_list_device_ids()`, `_get_raw_records(device_id, after_timestamp)` (Task 1); `_parse_all`, `_profiles_from`, `transform` (existing). -- Produces: `_fetch_raw_measurements(self, after_timestamp: int | None) -> list[EufyMeasurement]`. `fetch_measurements` now calls it when the normal path is empty. - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/test_eufy_client.py`: - -```python -def test_fetch_falls_back_to_raw_when_normal_empty(): - c = _client(customer_id="a") - raw = _record("a", 800, 2_000_000_000) # year 2033, passes the window - with patch.object(c, "_get_records", return_value=[]), \ - patch.object(c, "_list_device_ids", return_value=["dev1"]), \ - patch.object(c, "_get_raw_records", return_value=[raw]): - measurements = c.fetch_measurements(after_timestamp=1_500_000_000) - assert len(measurements) == 1 - assert measurements[0].weight_kg == 80.0 - assert measurements[0].customer_id == "a" - - -def test_fetch_does_not_use_raw_when_normal_has_data(): - c = _client(customer_id="a") - raw_probe = MagicMock() - with patch.object(c, "_get_records", return_value=[_record("a", 800, 2_000_000_000)]), \ - patch.object(c, "_list_device_ids", raw_probe): - measurements = c.fetch_measurements(after_timestamp=1_500_000_000) - assert len(measurements) == 1 - raw_probe.assert_not_called() - - -def test_raw_fallback_drops_other_profiles(): - c = _client(customer_id="a") - raws = [_record("a", 800, 2_000_000_000), _record("b", 600, 2_000_000_000)] - with patch.object(c, "_get_records", return_value=[]), \ - patch.object(c, "_list_device_ids", return_value=["dev1"]), \ - patch.object(c, "_get_raw_records", return_value=raws): - measurements = c.fetch_measurements(after_timestamp=1_500_000_000) - assert {m.customer_id for m in measurements} == {"a"} - - -def test_raw_fallback_degrades_when_device_list_errors(): - c = _client(customer_id="a") - with patch.object(c, "_get_records", return_value=[]), \ - patch.object(c, "_list_device_ids", side_effect=RuntimeError("boom")): - measurements = c.fetch_measurements(after_timestamp=1_500_000_000) - assert measurements == [] -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py -k "fall_back or does_not_use_raw or drops_other_profiles or degrades" -v` -Expected: FAIL. The first three fail because `fetch_measurements` returns `[]` without consulting the raw methods (it has no fallback yet); `degrades` fails because `_fetch_raw_measurements` does not exist. - -- [ ] **Step 3: Rewrite `fetch_measurements` to funnel to a fallback check** - -In `eufy_sync/eufy_client.py`, replace the current `fetch_measurements` (lines 254-269): - -```python - def fetch_measurements(self, after_timestamp: int | None = None) -> list[EufyMeasurement]: - if self.config.customer_id: - measurements = self._parse_all(self._get_records(after_timestamp)) - return [m for m in measurements if m.customer_id == self.config.customer_id] - - # No profile selected: read full history so the profile count is reliable - # even when only one person has weighed in recently. - measurements = self._parse_all(self._get_records(None)) - distinct = {m.customer_id for m in measurements} - if len(distinct) > 1: - raise AmbiguousProfileError(self._profiles_from(measurements)) - - if after_timestamp is not None: - cutoff = datetime.fromtimestamp(after_timestamp, tz=timezone.utc) - measurements = [m for m in measurements if m.timestamp >= cutoff] - return measurements -``` - -with this version: - -```python - def fetch_measurements(self, after_timestamp: int | None = None) -> list[EufyMeasurement]: - if self.config.customer_id: - parsed = self._parse_all(self._get_records(after_timestamp)) - measurements = [m for m in parsed if m.customer_id == self.config.customer_id] - else: - # No profile selected: read full history so the profile count is - # reliable even when only one person has weighed in recently. - parsed = self._parse_all(self._get_records(None)) - distinct = {m.customer_id for m in parsed} - if len(distinct) > 1: - raise AmbiguousProfileError(self._profiles_from(parsed)) - if after_timestamp is not None: - cutoff = datetime.fromtimestamp(after_timestamp, tz=timezone.utc) - measurements = [m for m in parsed if m.timestamp >= cutoff] - else: - measurements = parsed - - if measurements: - return measurements - # The normal endpoints had nothing in the window. This is the headless - # case: a weigh-in that the phone app has not processed yet. Fall back to - # the per-device raw Wi-Fi endpoint, which exposes the weight earlier. - return self._fetch_raw_measurements(after_timestamp) -``` - -- [ ] **Step 4: Add `_fetch_raw_measurements`** - -In `eufy_sync/eufy_client.py`, add this method directly after `fetch_measurements`: - -```python - def _fetch_raw_measurements(self, after_timestamp: int | None) -> list[EufyMeasurement]: - """Recover weight-only measurements from the raw Wi-Fi endpoint, applying - the same profile and window filters as the normal path. Degrades to an - empty list on any error so the run is never worse than today.""" - try: - device_ids = self._list_device_ids() - except Exception as e: - logger.warning("Raw Wi-Fi fallback: could not list devices: %s", e) - return [] - - records: list[dict] = [] - for device_id in device_ids: - try: - records.extend(self._get_raw_records(device_id, after_timestamp)) - except Exception as e: - logger.warning("Raw Wi-Fi fallback: fetch failed for %s: %s", device_id, e) - - measurements = self._parse_all(records) - raw_count = len(measurements) - - if self.config.customer_id: - measurements = [m for m in measurements if m.customer_id == self.config.customer_id] - if after_timestamp is not None: - cutoff = datetime.fromtimestamp(after_timestamp, tz=timezone.utc) - measurements = [m for m in measurements if m.timestamp >= cutoff] - - if measurements: - logger.info("Recovered %d weight-only measurement(s) from the raw Wi-Fi endpoint", len(measurements)) - elif raw_count: - logger.info( - "Raw Wi-Fi endpoint returned %d record(s) but none passed the profile or window filter " - "(raw records may not carry a profile id)", raw_count, - ) - return measurements -``` - -- [ ] **Step 5: Run the new tests to verify they pass** - -Run: `.venv/bin/python -m pytest tests/test_eufy_client.py -k "fall_back or does_not_use_raw or drops_other_profiles or degrades" -v` -Expected: PASS (4 passed). - -- [ ] **Step 6: Run the full suite to confirm no regression** - -Run: `.venv/bin/python -m pytest -q` -Expected: PASS (123 passed: 115 baseline + 8 new). - -- [ ] **Step 7: Commit** - -```bash -git add eufy_sync/eufy_client.py tests/test_eufy_client.py -git commit -m "$(cat <<'EOF' -Fall back to the raw Wi-Fi endpoint when the normal pull is empty - -When fetch_measurements finds nothing in the window (the headless case, before -the phone app processes a weigh-in), fetch per-device raw records and run them -through the same parser, profile filter, and window filter. Reuses the existing -transform, so weight-only records become clean weight-only uploads and an -implausible weight is dropped, never synced. Degrades to today's behavior on any -error. - -Addresses issue #2. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -### Task 3: Full-suite verification - -**Files:** none (verification only) - -- [ ] **Step 1: Run the entire suite** - -Run: `.venv/bin/python -m pytest -q` -Expected: PASS (123 passed). - -- [ ] **Step 2: Confirm the working tree and branch** - -Run: `git status --short && git branch --show-current` -Expected: no uncommitted source changes (an untracked `uv.lock` is fine); branch `raw-wifi-fallback`. - ---- - -## Self-Review - -**Spec coverage:** -- Trigger (fallback only when normal is empty) -> Task 2 Step 3. -- `_list_device_ids` -> Task 1. `_get_raw_records` (nullable `list`, res_code handling) -> Task 1. `_fetch_raw_measurements` (per-device loop, reused filters, diagnostic log) -> Task 2 Step 4. -- Reuse of parser/transform/filter -> Task 2 (no new parsing; `_parse_all` + the customer_id filter). -- Dedupe -> unchanged, relies on existing `is_synced` / `has_weight_on_date` (no code in this plan, correct per spec). -- Tests 1-6 from the spec -> Task 1 (device-list parse + error, raw-list extract + null/500/bad-code) and Task 2 (fallback fires only when empty, recovers weight-only, not called when normal has data, profile filter on raw, degrades on device-list error). - -**Placeholder scan:** none. Every step has concrete code and commands. - -**Type consistency:** `_list_device_ids() -> list[str]`, `_get_raw_records(device_id: str, after_timestamp: int | None) -> list[dict]`, and `_fetch_raw_measurements(after_timestamp: int | None) -> list[EufyMeasurement]` are used with the same names and signatures across Task 1 and Task 2. The `_resp(status_code, json_body)` helper is defined in Task 1 Step 1 and used only there. - -## Live test (post-merge, requires the user) - -Not a plan task. After merge, the user weighs in, does not open the Eufy app, and runs `eufy-sync --dry-run` from the repo so the raw path is exercised against a real pending record. A reported weight confirms the path works and that raw records carry a usable profile id. Nothing reported means raw records lack `customer_id`, and the follow-up is to relax the filter for the single-device case. diff --git a/docs/superpowers/specs/2026-05-17-zwift-weight-sync-design.md b/docs/superpowers/specs/2026-05-17-zwift-weight-sync-design.md deleted file mode 100644 index 5affda8..0000000 --- a/docs/superpowers/specs/2026-05-17-zwift-weight-sync-design.md +++ /dev/null @@ -1,177 +0,0 @@ -# Zwift Weight Sync - Design - -**Date:** 2026-05-17 -**Status:** approved, pending implementation plan -**Owner:** Elias - -## Goal - -Add Zwift as a third sync target in `eufy-sync`, so a new Eufy weight measurement updates the user's Zwift profile weight alongside Garmin Connect and Strava. Zwift uses weight for power-to-weight ratio in races, so keeping it accurate without manual entry is the value. - -## Constraints and Risks - -- Zwift has no public API for hobby developers. The Developer API is gated and `developers@zwift.com` access is not granted for personal projects. -- The path is community-reverse-engineered: Keycloak OAuth2 password grant for auth, `PUT /api/profiles/{id}` for the write. Both can change with any Zwift release. -- No maintained Python library implements the write. We are first to ship. -- Zwift's first-party Companion-app sync (Withings -> Zwift) has been reportedly broken for years per their forum, so users expect some flakiness in this space. - -The feature is shipped opt-in with explicit "unofficial, may break" framing in the setup flow. - -## Scope - -In scope: -- Update Zwift profile weight from the latest Eufy measurement on each sync. -- Token-based auth with automatic refresh, falling back to a stored password if the refresh token dies. -- Inclusion in first-run wizard, `--setup-zwift` for retrofitting, `--reauth zwift` for token refresh. -- Status/health reporting in `--status` output and the sync summary line. - -Out of scope (calling out so we do not drift): -- No FTP, height, or other profile fields. Weight only. -- No ride/workout pushes. -- No Companion-app handshake or Withings-style integration. -- No retroactive weight history. Zwift only stores current weight; backfill is meaningless for Zwift. - -## Architecture - -Mirrors the Strava client + Eufy password handling already in the codebase. One new module, parallel surface. - -### New module: `eufy_sync/zwift_client.py` - -Public surface: - -- `class ZwiftClient` - - `__init__(config: ZwiftConfig)` - - `authenticate() -> None` - loads cached tokens from keychain, refreshes if expired, falls back to fresh password-grant login if no refresh token. - - `update_weight(weight_kg: float) -> dict` - Zwift rejects JSON profile writes (HTTP 415); profiles are mutated via protobuf using the media type `application/x-protobuf-lite`. So we GET `/api/profiles/me` as protobuf, append `weight_in_grams` (PlayerProfile field 9, a varint; protobuf scalar fields are last-wins so this overrides the value while preserving the rest of the blob), and PUT it back. 4xx (except 429) -> `PermanentSyncError`; 5xx and 429 -> retried by `_retry`. Confirmed live: a 3.2 KB protobuf round-trip returns HTTP 204. - - `token_status() -> dict` - same shape as `StravaClient.token_status` and `GarminAuth.token_status` so the existing summary/status formatters work without special-casing. - - `close() -> None` - -Internal helpers: -- `_fresh_login()` - `POST https://secure.zwift.com/auth/realms/zwift/tokens/access/codes` with `grant_type=password`, `client_id=Zwift_Mobile_Link`, `username`, `password`. -- `_refresh_access_token()` - same URL, `grant_type=refresh_token`. -- `_load_tokens()` / `_save_tokens(tokens)` - keychain first, file fallback, same pattern as Strava's helpers. - -Token shape: -```json -{ - "access_token": "...", - "refresh_token": "...", - "expires_at": 1715948000 -} -``` - -Keychain key: `token:zwift`. File fallback: `~/.garmin-sync/zwift_token.json`. - -### Config additions: `eufy_sync/config.py` - -- `@dataclass class ZwiftConfig: email: str; password: str` -- `UserConfig.zwift: ZwiftConfig | None = None` -- `load_config` resolves the password the same way it resolves Eufy/Garmin passwords (`_get_password(name, "zwift", email, yaml_password)`). -- The "user has no sync targets" guard updates to include Zwift: a user must have at least one of `garmin`, `strava`, `zwift`. - -### Sync flow: `eufy_sync/sync.py` - -Two behavioral differences from the Strava code path: - -1. **One PUT per sync, not one per measurement.** Zwift's profile endpoint is heavier than Strava's `PUT /athlete`, and the user only cares about the final weight. So we find the newest unsynced measurement once and call `update_weight` once. Every measurement we considered gets a `record_sync(target="zwift")` entry so the duplicate check stays correct. - -2. **Zwift failures are isolated.** Today, if Strava throws inside `sync_user`, Garmin sync also stops for that measurement. For Zwift, which we expect to be the flakiest target, we wrap the Zwift work in its own try/except so a Zwift outage cannot block Garmin/Strava. The CLI summary reports per-target failures. - -Concretely, inside `sync_user`: - -```python -# After Garmin/Strava processing, before returning counts: -if user.zwift: - try: - zwift = ZwiftClient(user.zwift) - zwift.authenticate() - # "Unsynced for Zwift" = measurement not yet recorded in state DB with target="zwift". - unsynced = [m for m in measurements - if not state.is_synced(user.name, m.measurement_id, "zwift")] - if unsynced and not dry_run: - newest = unsynced[-1] # measurements list is sorted chronologically ascending - _retry(lambda: zwift.update_weight(newest.weight_kg), "Zwift update") - for m in unsynced: - state.record_sync( - user_name=user.name, - measurement_id=m.measurement_id, - measurement_timestamp=m.timestamp.isoformat(), - weight_kg=m.weight_kg, - synced_at=datetime.now(timezone.utc).isoformat(), - target="zwift", - response=None if m is not newest else json.dumps(result), - ) - counts["zwift"] = 1 - zwift.close() - except Exception as e: - logger.exception("Zwift sync failed; continuing") - zwift_error = str(e) # surfaced via the new errors channel below -``` - -`sync_user` returns `(counts, errors)` where `errors: dict[str, str]` maps target name to a failure message for that target only. The CLI summary prints any per-target errors alongside the success counts, so a user whose Zwift target failed still sees "Synced 1 measurement to Garmin and Strava; Zwift failed: ...". - -### CLI: `eufy_sync/cli.py` - -- First-run wizard: after the Strava prompt, ask `Connect Zwift? [y/N]`. Show the "unofficial, may break" notice before prompting for credentials so the user opts in eyes-open. -- `--setup-zwift` (new flag): adds or updates Zwift on an existing install. Same shape as `--setup-strava`. -- `--reauth zwift` (extended): treats Zwift like the other two for force-reauth. -- `--update-password`: now also prompts for the Zwift password. -- `--status`: prints a Zwift section using the existing `token_status` shape. -- Sync summary one-liner: includes Zwift counts when the target is configured. - -### Storage - -- Keychain entries: `default:zwift` (password) and `token:zwift` (refresh+access tokens). -- File fallback (no keychain): tokens at `~/.garmin-sync/zwift_token.json`, password in config YAML. -- `_uninstall` extends to clear `default:zwift` and `token:zwift`. - -### Error handling - -- Bad password on first login -> `PermanentSyncError`, surfaces in CLI as "Zwift login failed; run --update-password". -- 4xx on `PUT /api/profiles/me` -> `PermanentSyncError`, surfaces as "Zwift update failed (HTTP 4xx)". -- 5xx and network errors -> normal `_retry` behavior. -- Schema drift (Zwift response shape changes) -> caught by the per-target try/except in `sync_user`, logged, Zwift target marked failed for this run, Garmin/Strava continue. - -### Out-of-band: README and help text - -- Add a new "Sync targets" section to README listing Garmin (FIT upload), Strava (current weight), Zwift (current weight, unofficial). -- Setup wizard prints a one-liner about Zwift being unofficial before prompting. - -## Test Plan - -New tests: -- `tests/test_zwift_client.py` - - `token_status` returns `valid` / `refresh_needed` / `expired` / `no_session` in each branch. - - `authenticate` with valid token sets the bearer header. - - `authenticate` with expired access token triggers refresh. - - `authenticate` with no tokens triggers fresh password-grant login. - - `update_weight` sends grams (not kg). - - `update_weight` 401 raises `PermanentSyncError`. - - `update_weight` 500 raises a retryable error. - -Updated tests: -- `tests/test_sync.py` - - Three-target sync (Garmin + Strava + Zwift) makes exactly one Zwift PUT, regardless of how many measurements arrive. - - A Zwift exception during sync does not prevent Garmin uploads from being recorded. - - Zwift state-DB rows are written for every measurement considered. -- `tests/test_config.py` - - Loading a config with `zwift:` section parses into `ZwiftConfig`. - - User with only Zwift configured is accepted. - -## Open Questions - -None. All decisions resolved during brainstorming: - -- Auth UX: terminal-prompted password into keychain, same as Eufy. -- Update cadence: one PUT per sync, newest measurement, all measurements recorded in state DB. -- Failure isolation: per-target try/except so Zwift cannot break Garmin/Strava. -- First-run inclusion: yes, Zwift is in the wizard. -- Out-of-scope items locked: weight only, no Companion-app handshake, no other profile fields. - -## Success Criteria - -- `eufy-sync` first-run wizard offers Zwift with an "unofficial" notice. -- A successful sync updates the user's profile weight on Zwift's website to match the latest Eufy reading. -- `--status` shows Zwift token health alongside Garmin/Strava. -- Zwift breaking does not block Garmin/Strava sync; the CLI tells the user which target failed. -- Test suite stays green and adds Zwift-specific coverage. diff --git a/eufy_sync/__init__.py b/eufy_sync/__init__.py index d5281e4..1ebc56f 100644 --- a/eufy_sync/__init__.py +++ b/eufy_sync/__init__.py @@ -1,6 +1,6 @@ """Sync Eufy smart scale body composition data to Garmin Connect and Strava.""" -__version__ = "1.7.19" +__version__ = "1.7.21" # Public API for programmatic use from eufy_sync.garmin_auth import GarminAuth diff --git a/eufy_sync/eufy_client.py b/eufy_sync/eufy_client.py index 33184d1..98aa1a6 100644 --- a/eufy_sync/eufy_client.py +++ b/eufy_sync/eufy_client.py @@ -381,7 +381,7 @@ def _parse_record(self, record: dict) -> EufyMeasurement | None: logger.warning("Record has invalid weight: %s", raw_weight) return None - weight_kg = raw_weight / 10.0 # Eufy returns decigrams + weight_kg = raw_weight / 10.0 # Eufy returns weight in 0.1 kg units update_time = record.get("update_time", record.get("create_time", 0)) customer_id = record.get("customer_id", "unknown") measurement_id = f"{customer_id}_{update_time}" diff --git a/eufy_sync/garmin_auth.py b/eufy_sync/garmin_auth.py index 17d1792..19891f4 100644 --- a/eufy_sync/garmin_auth.py +++ b/eufy_sync/garmin_auth.py @@ -73,7 +73,7 @@ def handle_login_capture(source, result_json): is_mobile=True, ) context.expose_binding( - "pirateGarminCaptureLogin", + "eufySyncCaptureLogin", lambda source, data: handle_login_capture(source, data), ) context.add_init_script(""" @@ -86,7 +86,7 @@ def handle_login_capture(source, result_json): try { const clone = response.clone(); const data = await clone.json(); - window.pirateGarminCaptureLogin(JSON.stringify(data)); + window.eufySyncCaptureLogin(JSON.stringify(data)); } catch(e) {} } return response; @@ -101,7 +101,7 @@ def handle_login_capture(source, result_json): this.addEventListener('load', function() { if (this._url && this._url.includes('/mobile/api/login')) { try { - window.pirateGarminCaptureLogin(this.responseText); + window.eufySyncCaptureLogin(this.responseText); } catch(e) {} } }); diff --git a/pyproject.toml b/pyproject.toml index 59ca52e..6d7e644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "eufy-sync" -version = "1.7.20" +version = "1.7.21" description = "Sync Eufy smart scale body composition data to Garmin Connect and Strava" readme = "README.md" license = "MIT" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 81541de..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -httpx>=0.27.0 -garminconnect>=0.3.6 -curl_cffi>=0.7.0 -pyyaml>=6.0 -playwright>=1.40.0 diff --git a/tests/test_cli.py b/tests/test_cli.py index c03b9d7..95c93f6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -675,7 +675,7 @@ def boom_input(*a, **k): def test_status_with_corrupt_db_exits_cleanly(tmp_path): """If SyncState construction raises (locked keychain, corrupt DB file), --status must print a plain one-line error and exit 1 - not a raw - traceback. This is Pass 2's startup-guard pattern extended to the + traceback. This is the startup-guard pattern extended to the --status/--history handlers, which currently sit outside it.""" from eufy_sync.cli.app import main @@ -970,3 +970,19 @@ def boom_input(*a, **k): _notify.assert_any_call( "eufy-sync: choose your profile", "Run: eufy-sync --select-profile" ) + + +def test_dunder_version_matches_pyproject(): + """The version lives in two places (pyproject.toml for packaging, + eufy_sync.__version__ for --version and the update checker). 1.7.20 + shipped with the two out of sync, which made every up-to-date install + nag about a phantom update. This keeps them locked together.""" + import tomllib + from pathlib import Path + + import eufy_sync + + pyproject = Path(__file__).parent.parent / "pyproject.toml" + with open(pyproject, "rb") as f: + declared = tomllib.load(f)["project"]["version"] + assert eufy_sync.__version__ == declared diff --git a/tests/test_eufy_client.py b/tests/test_eufy_client.py index 174ada6..74ef410 100644 --- a/tests/test_eufy_client.py +++ b/tests/test_eufy_client.py @@ -15,7 +15,7 @@ def test_parse_record_basic(): "update_time": 1711900000, "create_time": 1711900000, "scale_data": { - "weight": 862, # decigrams -> 86.2 kg + "weight": 862, # 0.1 kg units -> 86.2 kg "body_fat": 18.5, "muscle_mass": 45.2, "water": 55.3, @@ -53,7 +53,7 @@ def test_parse_record_zero_weight(): # --------------------------------------------------------------------------- -# Helpers shared by Task 2 and Task 3 tests +# Shared record and client helpers # --------------------------------------------------------------------------- def _client(customer_id=None): @@ -88,7 +88,7 @@ def _raw_wifi_record(customer_id, weight_kg, timestamp): # --------------------------------------------------------------------------- -# Task 2: list_profiles +# list_profiles # --------------------------------------------------------------------------- def test_list_profiles_groups_by_customer_id_newest_first(): @@ -104,7 +104,7 @@ def test_list_profiles_groups_by_customer_id_newest_first(): # --------------------------------------------------------------------------- -# Task 3: fetch_measurements filtering and AmbiguousProfileError +# fetch_measurements filtering and AmbiguousProfileError # --------------------------------------------------------------------------- def test_fetch_filters_to_configured_profile(): @@ -152,7 +152,7 @@ def test_fetch_configured_profile_forwards_after_timestamp(): # --------------------------------------------------------------------------- -# Task 1: _list_device_ids and _get_raw_records +# _list_device_ids and _get_raw_records # --------------------------------------------------------------------------- def _resp(status_code, json_body): @@ -199,7 +199,7 @@ def test_get_raw_records_handles_null_list_500_and_bad_code(): # --------------------------------------------------------------------------- -# Task 2: fallback orchestration +# Raw Wi-Fi fallback orchestration # --------------------------------------------------------------------------- def test_fetch_falls_back_to_raw_when_normal_empty():