Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ build/
CLAUDE.md
.claude/
uv.lock
.superpowers/
81 changes: 0 additions & 81 deletions .superpowers/sdd/revert-zwift-report.md

This file was deleted.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -39,18 +37,18 @@ 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)

`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(...)`.
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Inline Eufy profile picker and quieter Garmin login output

Date: 2026-06-27
Status: approved, ready for implementation plan

## Problem

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Raw Wi-Fi weight fallback design

Date: 2026-06-27
Status: approved, ready for implementation plan

## Problem

Expand Down
Loading
Loading