From acdf9db2074a516c30fadfa38acc514ef28f4d8f Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 2 Oct 2026 09:39:50 -0400 Subject: [PATCH] Keep design notes and maintenance logs out of the repository --- .gitignore | 4 + ...026-06-18-eufy-profile-selection-design.md | 110 --------------- ...2026-06-18-garmin-auth-curl-cffi-design.md | 121 ----------------- .../2026-06-20-garmin-hybrid-auth-design.md | 99 -------------- ...ine-profile-picker-and-quiet-429-design.md | 109 --------------- ...6-06-27-raw-wifi-weight-fallback-design.md | 125 ------------------ ...6-07-10-actionable-notifications-design.md | 28 ---- .../2026-07-10-windows-support-design.md | 73 ---------- docs/research/2026-08.md | 47 ------- 9 files changed, 4 insertions(+), 712 deletions(-) delete mode 100644 docs/design/2026-06-18-eufy-profile-selection-design.md delete mode 100644 docs/design/2026-06-18-garmin-auth-curl-cffi-design.md delete mode 100644 docs/design/2026-06-20-garmin-hybrid-auth-design.md delete mode 100644 docs/design/2026-06-27-inline-profile-picker-and-quiet-429-design.md delete mode 100644 docs/design/2026-06-27-raw-wifi-weight-fallback-design.md delete mode 100644 docs/design/2026-07-10-actionable-notifications-design.md delete mode 100644 docs/design/2026-07-10-windows-support-design.md delete mode 100644 docs/research/2026-08.md diff --git a/.gitignore b/.gitignore index 51864f4..4375869 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ dist/ build/ *.plist uv.lock + +# Internal design notes and maintenance logs stay local +docs/design/ +docs/research/ diff --git a/docs/design/2026-06-18-eufy-profile-selection-design.md b/docs/design/2026-06-18-eufy-profile-selection-design.md deleted file mode 100644 index 41b1713..0000000 --- a/docs/design/2026-06-18-eufy-profile-selection-design.md +++ /dev/null @@ -1,110 +0,0 @@ -# Eufy Profile Selection - Design - -**Date:** 2026-06-18 -**Fixes:** GitHub issue #1 (syncs every household member's weight) - -## Goal - -On a shared Eufy account, the tool currently syncs every profile's weigh-ins to Garmin and Strava, so a user gets their partner's weight written into their own permanent health record. This adds a way to tell the tool which profile is yours, and makes it refuse to sync when it cannot tell, instead of guessing. - -## The bug, precisely - -The Eufy `/device/data` endpoint is keyed on the account, so it returns records for every profile that has weighed in. Each record carries a `customer_id`. `EufyClient._parse_record` reads that id into `EufyMeasurement.customer_id` and uses it only to build the dedup key (`f"{customer_id}_{update_time}"`). Nothing filters by it. `sync_user` then iterates all measurements and uploads each one. There is no profile field in `EufyConfig` and no way to select a profile from the CLI. - -## Scope - -In scope: -- An optional `customer_id` on the Eufy config that filters sync to one profile. -- Profile discovery so a user can identify themselves without knowing the opaque id. -- A safe stop when several profiles exist and none is selected. -- Selection during first-run setup, and a `--select-profile` command for existing installs. - -Out of scope: -- Cleaning up weigh-ins already written to Garmin or Strava before the fix. That history cannot be un-written safely. The fix stops the bleeding; the issue note will say so. -- Multi-profile-to-multi-target routing. `load_config` already rejects more than one user per install, so a single account maps to a single set of targets. - -## Behavior - -`customer_id` is the only stable per-profile identifier the API exposes, so it is what gets stored. The three cases at sync time: - -1. **`customer_id` is configured.** Only that profile's measurements sync. Everything else is dropped before upload. This is the fix. -2. **No `customer_id`, and the account has one profile.** Syncs it. This is the existing single-person setup, and its behavior does not change. -3. **No `customer_id`, and the account has more than one profile.** Syncs nothing. The sync stops, prints the detected profiles, and tells the user to run `eufy-sync --select-profile`. This is the safe stop. - -The decision in case 2 versus 3 must be based on every profile that has ever appeared on the account, not only the profiles with a measurement inside the current sync window. Otherwise a sync where only one household member weighed in recently would look single-profile and sync the wrong person. The implementation therefore determines the profile set from a full-history read when no `customer_id` is configured. - -## Architecture - -### Config: `eufy_sync/config.py` - -- `EufyConfig` gains `customer_id: str | None = None`. -- `load_config` reads it from the `eufy` section: `customer_id=u["eufy"].get("customer_id")`. It is plain configuration, not a secret, so it stays in the YAML file alongside the email (no keychain involvement). - -### Client: `eufy_sync/eufy_client.py` - -A new dataclass describes a profile for the picker: - -```python -@dataclass -class EufyProfile: - customer_id: str - last_measured: datetime - last_weight_kg: float - name: str | None = None -``` - -`name` is populated if the raw record exposes a human-readable label. Whether it does is verified against real account data during implementation; the reliable display is `last_weight_kg` + `last_measured` + the last few characters of `customer_id`, which is enough for a user to recognize their own weigh-in. - -New `list_profiles() -> list[EufyProfile]`: -- Reads the full history (`/device/data` with no `after`), groups parsed records by `customer_id`, and returns one `EufyProfile` per id holding the most recent weigh-in, sorted newest first. Used by the setup wizard and `--select-profile`. - -Changed `fetch_measurements(after_timestamp)`: -- If `self.config.customer_id` is set: fetch the requested window, parse, return only measurements whose `customer_id` matches. -- If it is not set: read full history once, derive the profile set from it. If more than one profile is present, raise `AmbiguousProfileError` carrying the profile list. If one or zero profiles are present, return the parsed measurements as before. The state DB dedup still ensures only new measurements sync, so reading full history here does not cause re-uploads. - -New exception `AmbiguousProfileError(Exception)`: -- Carries `profiles: list[EufyProfile]` so the CLI can render the picker guidance. -- Must not be retried. `sync.py::_is_permanent` is extended to treat it as permanent so `_retry` surfaces it immediately rather than spinning three times. - -### Sync: `eufy_sync/sync.py` - -- `_is_permanent` recognizes `AmbiguousProfileError`. -- No change to the `sync_user` return shape. The exception propagates out of `sync_user` (the `finally` still closes clients) and is handled by the CLI loop. Keeping the signature untouched avoids colliding with the later Zwift change that reworks it into `(counts, errors)`. - -### CLI: `eufy_sync/cli.py` - -- **First-run wizard:** after authenticating Eufy and before the first sync, call `list_profiles()`. If more than one profile is found, run the selection prompt and write the chosen `customer_id` into the config before syncing. A single-profile account proceeds with no prompt. -- **`--select-profile` (new flag):** loads config, authenticates Eufy, calls `list_profiles()`, prints a numbered list, prompts for a choice, and writes `customer_id` into `users[0]["eufy"]` via the existing `_write_config` helper. If only one profile exists, it says so and stores that id anyway so future syncs are unambiguous. Mirrors the shape of the existing `--setup-strava` flow (operates on `config["users"][0]`). -- **Sync loop:** wrap each user's `sync_user` call so an `AmbiguousProfileError` for one user prints the detected profiles plus the `--select-profile` instruction and continues, rather than aborting with a traceback. - -### Selection prompt - -Shared routine used by both the wizard and `--select-profile`: -- Prints each profile as a numbered line: most recent weight in kg and lb, the date, and a name if available. -- Reads a number, validates it, and returns the chosen `customer_id`. - -## Error handling - -- Configured `customer_id` that matches no current records: zero measurements, which flows through the existing "found 0 measurements" path. A clear log line notes that the selected profile had nothing new. -- `AmbiguousProfileError`: caught in the CLI loop, rendered as guidance, non-fatal for any other configured behavior. -- A user who selects the wrong profile re-runs `--select-profile` to change it. - -## Test plan - -New and updated tests: -- `tests/test_config.py`: an `eufy` section with `customer_id` parses into `EufyConfig.customer_id`; absence leaves it `None`. -- `tests/test_eufy_client.py`: - - `fetch_measurements` with a configured `customer_id` returns only matching measurements. - - `fetch_measurements` with no config and a single account profile returns all measurements (back-compat). - - `fetch_measurements` with no config and multiple account profiles raises `AmbiguousProfileError` carrying the profiles. - - `list_profiles` returns one entry per `customer_id` with the most recent weight and date, newest first. -- `tests/test_sync.py`: `AmbiguousProfileError` is treated as permanent (not retried) and propagates out of `sync_user`. -- `tests/test_cli.py`: `--select-profile` lists profiles and writes the chosen `customer_id` to the config (mocked input and `list_profiles`). - -## Success criteria - -- A configured profile syncs only that person's weigh-ins. -- A fresh single-profile install behaves exactly as before. -- A shared account with no profile selected stops, shows the profiles, and points the user to `--select-profile`, with nothing written to Garmin or Strava. -- `--select-profile` lets an existing install choose without editing config by hand. -- The test suite stays green and adds the coverage above. diff --git a/docs/design/2026-06-18-garmin-auth-curl-cffi-design.md b/docs/design/2026-06-18-garmin-auth-curl-cffi-design.md deleted file mode 100644 index 162303d..0000000 --- a/docs/design/2026-06-18-garmin-auth-curl-cffi-design.md +++ /dev/null @@ -1,121 +0,0 @@ -# Garmin Auth Swap: Playwright to python-garminconnect - Design - -**Date:** 2026-06-18 - -## Goal - -Replace the Playwright browser-based Garmin login with the browser-free `python-garminconnect` library (curl_cffi TLS impersonation). Let the library own Garmin login, token refresh, and the body-composition upload, while eufy-sync keeps its same-date duplicate check. This removes the heaviest dependency (Chromium), enables a fully headless first run, and offloads the Garmin authentication cat-and-mouse to an actively maintained community project. - -## Background - -Today Playwright is used for one thing only: the first interactive login. `browser_login` opens a Chromium window, injects JavaScript to intercept the login XHR, captures a service ticket, and exchanges it for DI OAuth2 tokens. Everything after that (token refresh, FIT generation, upload, dedup) already runs on plain httpx. So the change is surgical: replace the login + token plumbing + FIT encoder with library calls. - -`python-garminconnect` 0.3.x (latest 0.3.6, June 2026) logs in fully in-process via curl_cffi with a 5-strategy fallback, handles MFA via a callback, persists tokens as a JSON string (`dumps()`/`loads()`) suitable for the keychain, auto-refreshes the access token, and exposes `add_body_composition(...)` covering every field the Eufy scale produces. - -## Constraints and risks - -- The library is a new external dependency that itself chases Garmin's defenses. If it breaks, eufy-sync breaks until upstream ships a fix (track record: fixes within days, ~9 releases since April 2026). Mitigation: pin a minimum version; the risk is brief outages, not permanent breakage. -- curl_cffi impersonates a browser's TLS fingerprint, which is more detectable than a real browser long-term. It is the current winning approach, not a moat. -- `curl_cffi` is a native dependency (bundled libcurl). Lighter than Chromium, fine on macOS. -- Existing users must re-login once: their Playwright-era saved session is not loadable by the new code. - -## Scope - -In scope: -- Replace Garmin login + token management with `python-garminconnect`. -- Use the library's `add_body_composition` for upload; keep a same-date duplicate check via the library's read API. -- Terminal MFA prompt for interactive runs; clean fail-with-reauth for headless runs. -- One-time migration: old session is treated as absent, triggering a fresh login. -- Dependency, CLI, and README updates; test rewrite. - -Out of scope: -- Changing Strava, Eufy, or the sync orchestration beyond the one `authenticate` kwarg rename. -- Any change to `transform.py`'s validation logic (only its output is consumed differently). -- Keeping Playwright as a fallback (explicitly rejected; the point is to remove it). - -## Architecture - -### Deleted - -- `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) - -`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.) - -Internal: -- MFA callback: `lambda: input("Garmin MFA code (check your email): ").strip()`, passed as `prompt_mfa` to `Garmin(...)`. -- Token persistence reuses `eufy_sync.credentials`: `store_token("garmin", blob_dict)` / `get_token("garmin")` / `delete_token("garmin")`. The blob is the library's `{di_token, di_refresh_token, di_client_id}` dict (`json.loads(garmin.client.dumps())`). File fallback for headless Linux keeps `~/.garmin-sync/session.json` holding the same dict. - -### `eufy_sync/garmin_client.py` (kept surface, new internals) - -`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. - -### `eufy_sync/transform.py` (unchanged) - -Stays the Eufy-to-Garmin boundary. `GarminBodyComposition` remains the interface `GarminClient` consumes. - -### `eufy_sync/sync.py` (one-line change) - -`sync_user` calls `client.authenticate(allow_browser=not headless)` today; the kwarg becomes `allow_interactive=not headless`. Nothing else changes (it still calls `has_weight_on_date`, `upload_body_composition`, `close`). - -## Login, MFA, and headless behavior - -- **Interactive (TTY):** a fresh login prompts for the emailed MFA code in the terminal when Garmin requires it. -- **Cached runs:** restore the blob, library auto-refreshes the access token before calls; no interaction. -- **Headless (`--headless`, launchd):** `allow_interactive=False`. If a fresh login would be needed (no blob, or the refresh token is dead), the run fails with "run: eufy-sync --reauth" instead of blocking on a prompt. This also fixes the previously latent bug where the 401-refresh path ignored the headless flag. -- **Dead refresh token surfacing mid-operation:** if a restored session fails on the first upload/dedup call with the library's authentication error, `GarminClient` re-runs `force_reauth()` when interactive, or raises `PermanentSyncError` with the reauth message when headless. - -## Dependencies, CLI, README - -- `pyproject.toml` / `requirements.txt`: remove `playwright`; add `garminconnect>=0.3.6` and `curl_cffi`. -- `eufy_sync/cli.py`: remove `_ensure_chromium()` and its call; remove the "A browser window will open for Garmin login" wizard text (the first sync now logs in via the terminal automatically, prompting for MFA if needed); update the Garmin branches of `_reauth`, `_show_status`, and `_print_summary` to the new `GarminAuth` surface and status text. -- `README.md`: rewrite the "How Garmin login works" section to describe the browser-free curl_cffi login accurately (this is the fuller rewrite deferred from the earlier honesty fix). - -## Error handling - -- Bad Garmin credentials on a fresh login -> `PermanentSyncError` surfacing as "Garmin login failed; run --update-password". -- MFA required in a non-interactive run -> `PermanentSyncError("Garmin login needed; run: eufy-sync --reauth")`. -- Library auth error on a restored session -> interactive: `force_reauth()`; headless: `PermanentSyncError` with the reauth message. -- Transient network/5xx during upload -> the existing `_retry` in `sync.py` handles it (these are not `PermanentSyncError`). - -## Test plan - -Delete `tests/test_fit.py`. Rewrite `tests/test_garmin_auth.py`. All new tests mock `garminconnect.Garmin` (no network). - -- `GarminAuth.login` with a saved blob restores via `loads()` and does not call `login()`. -- `GarminAuth.login` with no blob and `interactive=True` calls `login()` and persists `dumps()`. -- `GarminAuth.login` with no blob and `interactive=False` raises `PermanentSyncError`. -- `GarminAuth.token_status` returns `valid` with a blob, `no_session` without. -- `GarminClient.upload_body_composition` calls `add_body_composition` with the mapped fields (assert the kwargs). -- `GarminClient.has_weight_on_date` returns True/False from the mocked read; returns False on a read exception. -- `sync.py` tests already mock `GarminClient`; confirm the `allow_interactive` rename does not break them. - -## Open questions - -None. Decisions resolved during brainstorming: -- Do the swap (vs keep Playwright vs hybrid): do it. -- Adoption depth: library owns login + refresh + upload; keep the same-date dedup. -- Headless: fail with reauth rather than ever prompting. -- One-time re-login for existing users: accepted. - -## Success criteria - -- A fresh install logs in to Garmin from the terminal with no browser window, prompting for MFA when required. -- A cached run syncs with no interaction; the access token refreshes automatically. -- A `--headless` run with no usable token fails with a clear "run --reauth" message instead of hanging. -- Body composition still lands in Garmin Connect with the same fields; the same-date dedup still prevents double-writes. -- Playwright and `fit.py` are gone; install no longer downloads Chromium. -- The test suite stays green with the library mocked. diff --git a/docs/design/2026-06-20-garmin-hybrid-auth-design.md b/docs/design/2026-06-20-garmin-hybrid-auth-design.md deleted file mode 100644 index 929a09a..0000000 --- a/docs/design/2026-06-20-garmin-hybrid-auth-design.md +++ /dev/null @@ -1,99 +0,0 @@ -# Garmin Hybrid Auth: curl_cffi primary, browser fallback (Design) - -**Date:** 2026-06-20 - -## Goal - -Make Garmin login resilient by trying the browser-free `python-garminconnect` (curl_cffi) login first and falling back to the Playwright browser login when it fails. This keeps headless and zero-hardware setups working through the curl_cffi path, while giving every interactive user the browser login as a reliable backstop when curl_cffi is rate-limited or the library's login is temporarily broken. - -## Background - -The curl_cffi swap (merged to main, version still 1.7.3 on PyPI) works, but live testing and the library's own tracker show its login is in active cat-and-mouse with Garmin: - -- The mobile login strategy returns account-wide 429s that follow a user across IPs. -- An open library issue (#369) has the post-login token-validation call returning 401 "Token is not active" for some accounts, intermittently, unresolved as of mid-June 2026. - -The library is well-maintained (2,456 stars, 4 open issues, releases every 1-2 weeks, responsive owner), and its multi-strategy cascade usually gets a user in. But login can fail, and login is the critical path. The Playwright browser login uses a real browser in a different auth bucket and is currently more reliable for interactive desktop use. The hybrid gets the benefits of both. - -Verified during design: the library's session serializes to `{di_token, di_refresh_token, di_client_id}`, and `client.loads()` hydrates a working session from any blob with those keys (`is_authenticated` is true when `di_token` is set; the API authorization header is `Bearer {di_token}`). So tokens obtained by the browser flow can be injected into the library session and used for upload. The browser path also bypasses the library's login-validation call, so it is immune to issue #369. - -## Constraints and risks - -- The browser fallback needs a display, so it cannot run in a headless context. -- Re-adding Playwright brings back the Chromium dependency. Mitigated by downloading the Chromium binary lazily, only when the fallback actually fires. -- The DI token exchange and the browser scraping are the old code being restored; they carry the same fragility they always did (Garmin can change the login page), but they are now a fallback, not the only path. - -## Scope - -In scope: -- Two-tier `GarminAuth.login()`: curl_cffi first, Playwright browser fallback on any fresh-login failure (interactive only). -- Bridging browser-obtained DI tokens into the library session and persisting them in the unified blob format. -- Lazy Chromium install, triggered only by the browser fallback. -- Re-adding `playwright` as a dependency; README and wizard text updates. - -Out of scope: -- Changing the upload, dedup, Eufy, or Strava paths. `GarminClient` is unchanged. -- Removing the curl_cffi 429 message handling and `_is_permanent` 429 rule added earlier; they still protect the headless and mid-sync cases. -- A headless browser fallback. Headless stays curl_cffi-only. - -## Architecture - -All changes are in `eufy_sync/garmin_auth.py` plus dependency, CLI text, and README edits. `garmin_client.py`, `sync.py`, `transform.py`, `config.py`, `credentials.py` are unchanged. - -### Restored from git history (pre-swap `garmin_auth.py`) - -- `browser_login(email, password) -> str`: opens Chromium via Playwright, auto-fills the stored credentials, intercepts the Garmin SSO login XHR, returns the service ticket. Identical to the pre-swap implementation. -- `_exchange_ticket_for_tokens(service_ticket) -> tuple[str, str, str]`: posts the service ticket to Garmin's DI OAuth2 endpoint and returns `(access_token, refresh_token, client_id)`. Adapted from the pre-swap version to also return the `client_id` it succeeded with (needed so the library can refresh the token later). -- Supporting constants and helper restored: `DI_TOKEN_URL`, `DI_CLIENT_IDS`, `DI_GRANT_TYPE`, `SERVICE_URL`, `SSO_LOGIN_URL`, `_basic_auth_header`. - -### New: the bridge and the two-tier login - -`GarminAuth.login(interactive: bool = True) -> Garmin`: - -1. Build the `Garmin` object and try to restore a saved blob via `client.loads()` (unchanged). Return it if it loads. -2. If no usable blob and `interactive` is False, raise `PermanentSyncError("Garmin login needed; run: eufy-sync --reauth")` (unchanged headless behavior; no browser). -3. **Tier 1 (curl_cffi):** call `garmin.login()`. On success, persist and return. -4. **Tier 2 (browser), only if Tier 1 raised any exception:** log that curl_cffi failed and the browser is opening, then run the browser fallback. On success, persist and return. -5. If the browser fallback also fails, raise a clear `PermanentSyncError`. - -`_browser_fallback(garmin: Garmin) -> None` (new): -- `_ensure_chromium()` (lazy install). -- `ticket = browser_login(self.email, self.password)`. -- `access, refresh, client_id = _exchange_ticket_for_tokens(ticket)`. -- `blob = {"di_token": access, "di_refresh_token": refresh, "di_client_id": client_id}`. -- `garmin.client.loads(json.dumps(blob))`. - -`force_reauth()` follows the same two-tier flow (curl_cffi fresh login, browser fallback), since it is always interactive. - -### Error handling - -- Tier 1 failure is never fatal on its own when interactive; it always falls through to the browser. -- Browser `browser_login` returns no ticket (wrong password, CAPTCHA, or the user closed the window): raise `PermanentSyncError("Garmin login failed. If you changed your password, run: eufy-sync --update-password. Otherwise re-run: eufy-sync --reauth")`. -- DI exchange failure after a captured ticket: raise `PermanentSyncError("Garmin token exchange failed; try: eufy-sync --reauth")`. -- A wrong password fails both tiers and ends on the update-password message, so the browser is the final arbiter and there is no loop. -- The earlier curl_cffi-specific 429/bad-credential conversion (`_fresh_login`) is folded into Tier 1: Tier 1 simply attempts `garmin.login()` and lets any exception trigger the fallback, so it no longer needs to translate messages itself. - -### Dependency and CLI - -- `pyproject.toml` / `requirements.txt`: re-add `playwright>=1.40.0` alongside `garminconnect` and `curl_cffi`. -- `eufy_sync/cli.py`: restore `_ensure_chromium()` but call it only from `_browser_fallback` in `garmin_auth.py` (lazy), never at startup. Update the first-run wizard line to "Logging in to Garmin (a browser may open if needed)." rather than promising no browser. -- `README.md`: rewrite "How Garmin login works" to describe the hybrid: curl_cffi first (browser-free, works headless), Playwright browser as an automatic fallback when needed. - -## Test plan - -All tests mock `garminconnect.Garmin`, `browser_login`, and `_exchange_ticket_for_tokens`; none open a real browser or hit the network. - -- Tier 1 success: `garmin.login()` succeeds, the browser fallback is never called, token saved. -- Restore path: a saved blob loads without any login attempt (unchanged behavior, keep the existing test). -- Fallback path: `garmin.login()` raises (e.g., `GarminConnectTooManyRequestsError`); `browser_login` + exchange succeed; the constructed blob is passed to `garmin.client.loads()`; token saved; the returned client is the same `Garmin`. -- Headless: `login(interactive=False)` with no saved blob raises `PermanentSyncError` and never calls `browser_login`. -- Bad credentials: Tier 1 raises, `browser_login` returns no ticket (raises), and `login` surfaces the update-password `PermanentSyncError`. -- `force_reauth`: clears the token, runs the two-tier flow, saves on success. - -## Success criteria - -- A normal interactive login uses curl_cffi and never opens a browser. -- When curl_cffi fails (rate-limited or library login broken), the browser opens automatically and the login completes, with the resulting tokens used for upload. -- A headless run with no token fails with a clear "run --reauth" message and no browser. -- Chromium is downloaded only when the browser fallback first runs; a user whose curl_cffi login works never downloads it. -- The test suite stays green with both paths mocked. diff --git a/docs/design/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 deleted file mode 100644 index e0fb59c..0000000 --- a/docs/design/2026-06-27-inline-profile-picker-and-quiet-429-design.md +++ /dev/null @@ -1,109 +0,0 @@ -# Inline Eufy profile picker and quieter Garmin login output - -Date: 2026-06-27 - -## Problem - -Two rough edges showed up in one `eufy-sync` run on 2026-06-27. - -1. The account has more than one Eufy profile: an 88 kg human profile and a 4.5 kg - pet profile. With no profile selected, the sync refuses to guess and fails with - a dead end. It prints the profiles, tells the user to run - `eufy-sync --select-profile`, and syncs nothing. The user has to read the - message, run a second command, then run the sync a third time. - -2. The Garmin login printed two WARNING lines about 429 rate limits even though - login succeeded. The python-garminconnect library tries several login - strategies in order. `mobile+cffi` and `mobile+requests` each hit a 429 and - logged a warning, then a later strategy succeeded. The warnings report attempts - that did not change the result, and they look alarming. - -## Goals - -- An interactive run that hits profile ambiguity resolves it in place and finishes - the sync, with no second command. -- A successful Garmin login produces no rate-limit noise on a normal run. -- Neither change hides a genuine failure or a run where no human is present to - answer a prompt. - -## Non-goals - -- No heuristic to guess which profile is the human. The user picks once, and the - choice is remembered. -- No edits to the python-garminconnect library text or its em-dash. That code - lives in installed site-packages and would be overwritten on the next reinstall. -- No multi-user handling. `load_config` already enforces one user per install. - -## Design - -### Part 1: Inline profile picker - -Resolve `AmbiguousProfileError` in the CLI sync loop (`eufy_sync/cli.py`, the -`except AmbiguousProfileError` block near line 977). - -A human is considered present when `not args.headless and sys.stdin.isatty()`. -The `isatty()` check means a scheduled run that forgot `--headless` still will not -hang waiting on input. - -When a human is present: - -1. Show the profile list and prompt with the existing `_prompt_profile_choice(e.profiles)`. -2. Persist the chosen `customer_id` to the config file through a new shared helper - `_save_customer_id(config_path, customer_id)`, extracted from the current - `_select_profile` so the write lives in one place. -3. Set `user.eufy.customer_id` in memory. `EufyConfig` is a mutable dataclass. -4. Re-run `sync_user(user, ...)` once and account the result like a normal success. - -When no human is present (`--headless` or no TTY): keep today's behavior. Print the -profile list and the `eufy-sync --select-profile` guidance, record the failure, and -fire a macOS notification with its own clear text instead of the generic -"eufy-sync failed". - -Single-user invariant: `load_config` raises if the config holds more than one user, -so the chosen profile always belongs to `users[0]`. The existing `_select_profile` -already relies on this. - -### Part 2: Quiet the Garmin 429 retry noise - -Extract the logging setup that currently sits inline in the sync path -(`eufy_sync/cli.py:951-958`) into `_configure_logging(verbose: bool)`: - -- Root level DEBUG when verbose, WARNING otherwise. Unchanged. -- httpx logger to WARNING. Unchanged. -- New: `garminconnect` logger to ERROR when not verbose. - -Call `_configure_logging` from the sync path and the `--reauth` path. Both trigger -a Garmin login and can emit the same noise. - -Why ERROR is safe: the library logs each failed strategy at WARNING and only raises -an exception when every strategy fails. That exception propagates to our code, which -reports it through our own message and notification. `sync.py` marks -`GarminConnectTooManyRequestsError` permanent (line 31), so the failure is surfaced -on our terms. Suppressing below ERROR removes the per-strategy noise without hiding -a genuine failure. `--verbose` restores full detail. - -## Behavior matrix - -| Run type | Profiles ambiguous | Garmin login succeeds after 429s | -| --- | --- | --- | -| Interactive (TTY, no `--headless`) | Prompt, save choice, finish sync | Silent | -| Headless or no TTY | Print guidance, notify, exit non-zero | Silent | -| `--verbose` | Same as above, plus full logs | Full per-strategy logs | - -## Tests (TDD) - -1. `_save_customer_id` writes the chosen id into the YAML config and leaves the - other fields intact. -2. Interactive ambiguous-profile path: with `sys.stdin.isatty()` mocked True and - `input` returning "1", the run saves the choice and retries the sync, producing - a synced measurement and a success exit. -3. Non-interactive path: with no TTY or `--headless`, `input` is never called, the - guidance prints, and the run exits non-zero. -4. `_configure_logging(verbose=False)` sets the `garminconnect` logger level to - ERROR. `_configure_logging(verbose=True)` does not raise it above its default. - -## Files touched - -- `eufy_sync/cli.py`: inline picker in the sync loop, `_save_customer_id` helper, - `_configure_logging` helper, dedicated notification text for the headless case. -- `tests/test_cli.py`: the new tests above. diff --git a/docs/design/2026-06-27-raw-wifi-weight-fallback-design.md b/docs/design/2026-06-27-raw-wifi-weight-fallback-design.md deleted file mode 100644 index 601a6b6..0000000 --- a/docs/design/2026-06-27-raw-wifi-weight-fallback-design.md +++ /dev/null @@ -1,125 +0,0 @@ -# Raw Wi-Fi weight fallback design - -Date: 2026-06-27 - -## Problem - -Eufy's cloud only exposes a weigh-in through the normal endpoints after the phone -app has processed it. On a headless or scheduled setup, a run that fires before -the app is opened gets nothing, so the weight never reaches Garmin or Strava until -someone opens the app by hand. This breaks the tool's core promise of headless, -zero-touch syncing. It is the substance of issue #2, where the reporter (gwgr) -found a workaround we never implemented. - -The workaround: a per-device raw Wi-Fi endpoint returns the new weight before the -app processes it. A read-only probe against a live account on 2026-06-27 confirmed -the endpoint is real and reachable (`res_code: 1`), and that records live under a -`list` field rather than the `data` field the normal endpoints use. The endpoint -returned empty in the probe because that account was already synced, so a populated -raw record was not captured. That single unknown (the raw record's exact fields, -and whether it carries a `customer_id`) is resolved by the live test below. - -## Goals - -- When the normal pull returns no new measurement in the window, recover the - weight from the raw Wi-Fi endpoint so a headless run still syncs. -- Never sync a wrong or implausible weight, and never sync another profile's weight. -- Reuse the existing parser, transform, profile filter, and dedupe. Add no config. - -## Non-goals - -- No retroactive enrichment. If the app is opened later and the enriched record - appears, that date stays weight-only on Garmin. Enrich-later is a possible v2. -- No change to the normal path when it returns data. -- No new config toggle. The fallback is automatic and safe. - -## Design - -### Trigger - -Inside `EufyClient.fetch_measurements`, after the existing normal path produces the -windowed, profile-filtered list, fall back only when that list is empty: - -``` -measurements = -if measurements: - return measurements -return self._fetch_raw_measurements(after_timestamp) -``` - -The existing `AmbiguousProfileError` branch (multiple profiles, none selected) still -runs first on the normal path, so the fallback is only reached when a profile is -configured or the account has a single profile. - -### Three new EufyClient methods - -- `_list_device_ids() -> list[str]`: `GET /device/v2` with the `Token`/`Uid` - headers, return `[d["id"] for d in body.get("devices", []) if d.get("id")]`. - Return `[]` on `res_code != 1`. -- `_get_raw_records(device_id, after_timestamp) -> list[dict]`: - `GET /device/wifi_scale/raw_data/{device_id}` with `after` as a string param when - set. Return `body.get("list") or []`. Return `[]` on a non-200 status or - `res_code != 1`. The `list` field can be JSON `null`, hence `or []`. -- `_fetch_raw_measurements(after_timestamp) -> list[EufyMeasurement]`: for each - device id, collect raw records, parse them with the existing `_parse_all`, then - apply the same `customer_id` filter and `after` window filter the normal path - uses. Log how many measurements were recovered. When raw records were parsed but - all dropped by the profile filter, log that the raw records carried no matching - profile id, so the live test is diagnostic. - -### Why this reuses the existing pipeline - -- Weight-only records need no new parsing. `_parse_record` reads - `scale_data.weight` (decigrams), and `transform` clamps a zero body-fat, muscle, - or water value to `None`, so a raw record with zeroed body composition becomes a - clean weight-only upload. -- A unit mismatch is self-protecting. `transform` rejects any weight outside - 22.7 to 181.4 kg, so a misread raw weight is dropped, never synced wrong. -- Profile attribution reuses the normal filter `m.customer_id == config.customer_id`. - If raw records carry a `customer_id`, the selected profile is honored. If they do - not, that filter drops them for a profile-configured account (the pet can never - slip through) while a single-profile account with no selection keeps them. - -### Dedupe - -No new logic. If the enriched record later arrives with the same id, `is_synced` -skips it. If it arrives with a different id but the same date, the Garmin -`has_weight_on_date` check skips it. Strava re-applies the same current weight, -which is harmless. - -### Auth - -The fallback runs only after the normal `_get_records` call has already -authenticated and, if needed, refreshed the token, so the fallback GETs use a valid -token. On any auth or server error they return `[]` and the run degrades to today's -behavior. - -## Testing - -Unit tests with mocked HTTP responses (the real endpoint cannot be exercised -because it is empty when an account is synced): - -1. `_list_device_ids` parses `devices[].id` from a `/device/v2` body, and returns - `[]` on `res_code != 1`. -2. `_get_raw_records` returns the `list` array, returns `[]` when `list` is `null`, - and returns `[]` on a 500 or `res_code != 1`. -3. `fetch_measurements` falls back to the raw path only when the normal path is - empty, and recovers a weight-only measurement from a mocked raw record. -4. `fetch_measurements` does not call the raw endpoint when the normal path returns - data. -5. The profile filter applies to raw records: a raw record whose `customer_id` - matches the configured profile is kept, a mismatch is dropped. -6. The fallback degrades to `[]` when `/device/v2` errors. - -## Live test (also the raw-record capture) - -Weigh in, do not open the Eufy app, then run `eufy-sync --dry-run`. If it reports -the new weight, the raw path works and raw records carry a usable profile id. If it -reports nothing, raw records lack a `customer_id`, and the follow-up is to relax the -filter for the single-device case. Either outcome is safe. - -## Files touched - -- `eufy_sync/eufy_client.py`: the trigger in `fetch_measurements` and the three new - methods. -- `tests/test_eufy_client.py`: the six tests above. diff --git a/docs/design/2026-07-10-actionable-notifications-design.md b/docs/design/2026-07-10-actionable-notifications-design.md deleted file mode 100644 index 21d8922..0000000 --- a/docs/design/2026-07-10-actionable-notifications-design.md +++ /dev/null @@ -1,28 +0,0 @@ -# Actionable macOS notifications - Design - -**Date:** 2026-07-10 - -## Problem - -Failure notifications (Garmin re-login, Eufy password change, profile selection, update available) tell the user exactly what to run, but clicking one opens Script Editor. That happens because the tool posts notifications through `osascript`, and macOS attributes those to Script Editor. Plain `osascript` has no way to attach a click action, so the click is a dead end. - -## Design - -`_notify(title, message)` in `cli/shared.py` gains an optional `command` parameter carrying the fix command the notification already names in its text. - -When a command is present and [terminal-notifier](https://github.com/julienXX/terminal-notifier) is installed, the notification goes out through it with an `-execute` action: clicking tells Terminal to open a new window, run the command, and come to the front. The user lands directly in the interactive prompts (Garmin login, password entry, profile picker). - -In every other case, behavior is unchanged: no command, no terminal-notifier, or a non-macOS host all take the existing `osascript` path, which fails silently off-platform as before. - -terminal-notifier is looked up with `shutil.which` plus the two standard Homebrew locations (`/opt/homebrew/bin`, `/usr/local/bin`), because scheduled runs under launchd get a minimal PATH that may not include Homebrew. - -Call sites that gain a command: the three re-auth style failures in `cli/app.py` and the update notice in `cli/updater.py`. Success and generic-failure notifications stay plain; there is nothing useful for a click to do. - -## Non-goals - -- No new required dependency. terminal-notifier stays optional; the README mentions it in one line. -- No change to notification text or to which events notify. - -## Testing - -Unit tests on `_notify` with `shutil.which` and `subprocess.run` monkeypatched: the terminal-notifier invocation includes the command inside the `-execute` action; absence of terminal-notifier falls back to `osascript`; a notification without a command uses `osascript` even when terminal-notifier is available. diff --git a/docs/design/2026-07-10-windows-support-design.md b/docs/design/2026-07-10-windows-support-design.md deleted file mode 100644 index 44d7842..0000000 --- a/docs/design/2026-07-10-windows-support-design.md +++ /dev/null @@ -1,73 +0,0 @@ -# Windows support - Design - -**Date:** 2026-07-10 - -## Problem - -eufy-sync is macOS only (plus headless Linux for the sync itself). The sync engine, the credential vault, and the Garmin login are already platform-neutral Python; what is macOS-specific is the plumbing around them: launchd for scheduled sync, osascript for notifications, and a handful of doctor checks. Garmin's user base skews Windows, so Windows users are the largest group the "macOS only" line in the README turns away. - -Five things stand between the current code and a working Windows release: - -1. Auto-sync has no Windows implementation (launchd only). -2. Notifications go through osascript, which does not exist on Windows. -3. Windows Credential Manager caps one stored secret at 2,560 bytes; the single-JSON vault can exceed that once it holds Garmin's two OAuth tokens plus Strava's. -4. Windows locks a running executable, so `--update` replacing eufy-sync while eufy-sync runs it can fail. -5. Doctor, CI, and the README all assume macOS. - -## Design - -### Platform layer - -A new `eufy_sync/platform_support/` package owns everything OS-specific behind one interface: - -- `notify(title, message, command=None)` - user notification, click action optional per platform -- `install_agent()` / `uninstall_agent()` - set up or remove scheduled sync -- `agent_status()` - installed / not installed / broken, for doctor and status - -Three implementations, selected once at startup from `platform.system()`: - -- `macos.py` - the existing launchd and osascript code moves here unchanged in behavior, including the stable-wrapper trick and the terminal-notifier click action. -- `windows.py` - new, described below. -- `generic.py` - no-ops with honest messages ("Auto-sync is not managed on this platform; see the Headless Linux section of the README"). Headless Linux keeps its user-owned systemd timer exactly as today. - -`cli/maintenance.py` and `cli/shared.py` shrink accordingly; call sites ask the platform layer instead of branching on OS. - -### Windows auto-sync - -`--install-agent` creates a per-user Scheduled Task named `eufy-sync` via `schtasks /Create /SC HOURLY /MO 4` (no admin rights needed). The task runs a small VBScript wrapper stored in `~/.garmin-sync/`, which launches `eufy-sync --headless` with the console window hidden and stdout/stderr appended to the existing `sync.log`. Without the wrapper, a console window would flash into focus every 4 hours. As on macOS, the wrapper file's bytes stay identical across releases so the registered task never has to change; it is rewritten only when its content would differ. - -`--uninstall-agent` and `--uninstall` remove the task with `schtasks /Delete` and delete the wrapper. - -### Windows notifications - -`notify()` on Windows sends a native toast through PowerShell's WinRT APIs (built into Windows 10/11, no new dependency). One deliberate difference from macOS: no click-to-run action in v1. Windows toasts cannot start a terminal command without registering a protocol handler, which is more machinery than the feature is worth. The toast text names the fix command instead, which every notification already does. As on macOS, notification failures are swallowed; a lost toast must never break a sync. - -### Credential vault chunking - -The vault stays one JSON object stored under one logical name. The keychain backend gains transparent chunking: if the serialized vault exceeds a conservative per-entry limit (1,200 characters, safely under Credential Manager's 2,560-byte UTF-16 ceiling), it is split across numbered entries (`vault`, `vault:1`, `vault:2`, ...) and reassembled on read. Writes replace all chunks and delete leftovers from a previously longer vault, so a shrinking vault cannot leave stale tail chunks. The chunking is platform-neutral; macOS keychain entries never hit the limit, so behavior there is unchanged in practice. The 0o600 file fallback stays as the safety net, with one caveat noted in the README: Windows does not honor POSIX file modes, so on Windows the file's protection is the user profile's ACL. - -### Self-update on Windows - -`--update` on Windows cannot replace `eufy-sync.exe` while it is running. Instead of running the package manager inline, the Windows path launches it in a new detached console that waits two seconds for eufy-sync to exit, then runs the pinned install command (uv, pipx, or pip, same detection as today) and leaves the window open showing the result. macOS and Linux keep the inline path. - -### Doctor, CI, docs - -- Doctor: the launchd check becomes a platform-layer `agent_status()` check, so Windows gets "scheduled task installed and healthy" with the same PASS/WARN/FAIL reporting. -- CI: `windows-latest` joins the test matrix in `test.yml` alongside ubuntu and macos. -- README: drop "macOS only", add a Windows install section (uv's PowerShell one-liner, mirroring the existing uv path), document the toast behavior and the auto-sync task, keep Headless Linux as is. -- Packaging: add `Operating System :: Microsoft :: Windows` to the pyproject classifiers, which currently list only macOS and Linux. - -### First-run offer - -The "Set up automatic sync every 4 hours?" prompt after first-run setup is currently gated to macOS. It moves behind the platform layer and fires on Windows too, installing the scheduled task on a yes. The generic platform keeps it silent. - -## Non-goals - -- No clickable toast actions on Windows in v1. -- No MSI/installer or winget package; install stays pipx/uv. -- No change to sync logic, Garmin auth, Eufy client, or targets. -- No systemd agent management on Linux; the README recipe remains user-owned. - -## Testing - -Unit tests per platform module with subprocess calls mocked: schtasks create/delete/query argument shapes, the VBScript wrapper's content and rewrite-only-on-change behavior, PowerShell toast invocation, and the generic no-ops. Vault chunking is tested for real (round-trip at sizes below, at, and above the chunk limit; shrink leaves no stale chunks; single-chunk vaults keep today's storage shape so existing installs read cleanly). The full suite runs on Windows CI. Before release, a manual pass on a real Windows machine: install, first-run setup, a real sync, the scheduled task firing with no visible window, a forced failure toast, `--update`, and both uninstall commands. Ships as 1.8.0. diff --git a/docs/research/2026-08.md b/docs/research/2026-08.md deleted file mode 100644 index 1fd2652..0000000 --- a/docs/research/2026-08.md +++ /dev/null @@ -1,47 +0,0 @@ -# Research notes: August 2026 - -First sweep. State as of 2026-08-14; the baseline is the 1.8.1 release (2026-07-24). - -## Our tracker - -- [#56](https://github.com/sturimcode/eufy-sync/issues/56): backfill stamps history with the wrong date on some accounts. Eufy bulk-rewrites `update_time` server-side, so `--backfill-days` can collapse years of weigh-ins onto one date (one report: 318 of 319 records sharing a single timestamp). `create_time` survives the rewrite and is the right field. Forward sync is unaffected. Needs a fix plus a recovery route for anyone who already ran a bad backfill. -- [#58](https://github.com/sturimcode/eufy-sync/issues/58): deleting entries in the Eufy app but not in Garmin leaves local state claiming records are synced when Garmin has nothing. The ask is a `--repair` style flag that re-syncs a date range regardless of recorded state. The same flag would double as the #56 recovery route. -- #57 (closed): the documented Windows install fails on hosts that reject the Let's Encrypt chain (seen on Windows Server 2016). Worth a troubleshooting entry. -- CI is green on all six legs. No open PRs; no forks carrying their own commits. - -## Upstream - -- garminconnect is at 0.3.10; we floor at 0.3.6. Verified at source level across the span: `add_body_composition` and `Garmin.__init__` signatures unchanged, no API removals, our exception imports and 401/403 message matching intact. 0.3.10 is mostly a token-storage hardening release (atomic writes, symlink rejection, tokens scrubbed from logs), which is a good reason to raise the floor. -- Our `curl_cffi>=0.7.0` floor understates reality: garminconnect 0.3.10 requires `>=0.15.0`. The resolver picks the higher one, so nothing breaks, but the declared floor should tell the truth. -- Garmin: no platform changes since the March 2026 Cloudflare enforcement. The standing enforcement still produces account-scoped 429s that outlast IP changes. One recurring false alarm in the wild: stale cached tokens look exactly like a Cloudflare block until the token store is cleared. Troubleshooting entry candidate. -- Strava: `PUT /athlete` and its `weight` field are unchanged. The change that matters is commercial: since June 2026 Strava requires a paid subscription for API access (existing developers since 2026-06-30). Our README does not mention this prerequisite yet. Two 2027 deadlines: tokens must move to request headers (we already send `Authorization: Bearer`), and the API base URL migrates off `www.strava.com/api/v3` by 2027-06-01, but Strava's own docs currently disagree on the new hostname, so nothing to code yet. -- Python 3.14 has been stable since October 2025 (now 3.14.7) and is absent from our CI matrix; 3.12 has dropped to security-only fixes. Add 3.14 to the matrix. -- keyring, playwright, httpx, and pyyaml all resolve cleanly from our floors. garth's deprecation is old news and already covered in the README. - -## Ecosystem - -- BLE Scale Sync (KristianP26/ble-scale-sync) is the serious alternative: local BLE capture, weekly releases, Garmin and Strava among many export targets. On the P2 and P2 Pro it currently syncs weight only and estimates body composition from BMI; its issue #289 tracks decoding the scale's impedance bytes and got promising captures on 2026-08-08. If that lands, the practical difference between the projects narrows to cloud capture (no listener hardware near the scale, weigh-ins collected even when nothing is running at home) and reporting Eufy's own body-comp figures instead of a third-party formula. -- No sign of native Garmin or Strava export coming to EufyLife: the iOS app has not shipped a release since December 2025 and the support docs still list Apple Health, Google Fit, and Fitbit only. -- EufyLife's Android sync now routes through Health Connect (the Google Fit APIs are retiring), but Garmin's Health Connect integration is export-only, so no new path opens there. -- The only other project consuming the EufyLife cloud scale API has been dormant since March 2026, so this project is now effectively the canary for any Eufy endpoint or auth change. - -## Actionable - -1. Fix #56: prefer `create_time`; decide the recovery route for users who backfilled bad dates. -2. Build the `--repair` resync flag from #58; it covers the recovery cases of both open issues. -3. Reply to #56 and #58. -4. README: note the Strava API subscription prerequisite; add troubleshooting entries for the stale-token false alarm and the Windows TLS chain failure. -5. Raise the garminconnect floor to 0.3.10 and the curl_cffi floor to 0.15.0. -6. Add Python 3.14 to CI, and consider a weekly scheduled CI run so dependency breaks surface between releases. - -## Incident 2026-08-28: hung re-auth prompt - -A `--reauth garmin` run sat on an unanswered prompt for hours and took an unrelated upgrade down with it. On 1.9.1: - -- 12:25 PM: the scheduled headless sync failed with a Garmin 401. The toast told the user to run `eufy-sync --reauth garmin`. -- 1:13:56 PM: the user ran that command in a PowerShell window. A saved token blob existed, so the command printed "Garmin is already connected. Re-authenticate anyway? [y/N] " and blocked in `input()`. Nobody answered it. A py-spy dump of the live process showed one thread, parked at that `input()` call (maintenance.py line 108). -- 2:28:58 PM: `uv tool install --force` failed with "Access is denied" (1.10.0 had reached PyPI at 2:23 PM; the time is the venv Scripts directory's mtime). The parked process held a file lock on `Scripts\python.exe` inside the uv tool venv, and the failed upgrade deleted the venv's site-packages on the way out. Syncs were dead from that moment until a reinstall at ~4:45 PM. - -The standing hypothesis was a thread leak: non-daemon threads left running by curl_cffi or garminconnect after a completed sync. That is wrong. The process had exactly one thread, and the run had not completed - it was still waiting at the prompt. The prompt code was unchanged in 1.10.0. - -The fix is a 5-minute timeout on the two prompts reachable from a failure toast: the re-auth confirmation and the Garmin MFA prompt. Both now read through `eufy_sync.prompt.input_with_timeout`. On timeout each takes its safe default - keep the current Garmin login, cancel the MFA login - and the process exits. A console read already in progress cannot be cancelled, so the reader is a daemon thread that holds no locks and cannot delay that exit. Prompts the user runs deliberately (setup, `--update-password`, `--uninstall`) are unchanged.