From 14183d7e4ad3300359547bec4ba299028cfa6c78 Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 2 Oct 2026 11:31:01 -0400 Subject: [PATCH 1/5] Add Intervals.icu as a sync target --- eufy_sync/cli/app.py | 37 ++- eufy_sync/cli/doctor.py | 23 ++ eufy_sync/cli/maintenance.py | 23 +- eufy_sync/cli/setup.py | 84 ++++++- eufy_sync/cli/status.py | 20 +- eufy_sync/config.py | 47 +++- eufy_sync/intervals_client.py | 141 ++++++++++++ eufy_sync/reporting.py | 10 +- eufy_sync/state.py | 23 ++ eufy_sync/sync.py | 138 ++++++++++-- pyproject.toml | 3 +- tests/test_cli.py | 14 +- tests/test_doctor.py | 60 +++++ tests/test_intervals_client.py | 209 +++++++++++++++++ tests/test_intervals_sync.py | 376 +++++++++++++++++++++++++++++++ tests/test_lock.py | 2 + tests/test_setup_intervals.py | 401 +++++++++++++++++++++++++++++++++ tests/test_zwift_sync.py | 2 +- 18 files changed, 1571 insertions(+), 42 deletions(-) create mode 100644 eufy_sync/intervals_client.py create mode 100644 tests/test_intervals_client.py create mode 100644 tests/test_intervals_sync.py create mode 100644 tests/test_setup_intervals.py diff --git a/eufy_sync/cli/app.py b/eufy_sync/cli/app.py index 61f8762..358c9ae 100644 --- a/eufy_sync/cli/app.py +++ b/eufy_sync/cli/app.py @@ -9,7 +9,7 @@ from eufy_sync import platform_support from eufy_sync.cli import doctor, maintenance, profiles, setup, shared, status, updater -from eufy_sync.reporting import SyncReport, garmin_existing_note, update_counts_summary +from eufy_sync.reporting import SyncReport, display_name, garmin_existing_note, update_counts_summary logger = logging.getLogger("eufy_sync") NETWORK_RETRY_DELAY = 60 @@ -18,7 +18,11 @@ def _target_label(total_counts: dict[str, int]) -> str: """Human label for the targets that received data, e.g. "Garmin and Strava".""" - names = [n.capitalize() for n in total_counts if total_counts[n] > 0] + return _join_names([display_name(n) for n in total_counts if total_counts[n] > 0]) + + +def _join_names(names: list[str]) -> str: + """"A", "A and B", or "A, B and C".""" if len(names) < 3: return " and ".join(names) return f"{', '.join(names[:-1])} and {names[-1]}" @@ -146,7 +150,7 @@ def _main() -> None: parser = argparse.ArgumentParser( prog="eufy-sync", - description="Sync Eufy smart scale data to Garmin Connect, Strava, and Zwift", + description="Sync Eufy smart scale data to Garmin Connect, Strava, Zwift, and Intervals.icu", ) parser.add_argument("--version", "-V", action="version", version=f"eufy-sync {__version__}") parser.add_argument("--status", action="store_true", help="Show sync status and token health") @@ -157,7 +161,9 @@ def _main() -> None: parser.add_argument("--setup-strava", action="store_true", help="Connect Strava to your account") parser.add_argument("--setup-zwift", action="store_true", help="Connect experimental Zwift weight sync") parser.add_argument("--disconnect-zwift", action="store_true", help="Disconnect Zwift weight sync") - parser.add_argument("--target", choices=("garmin", "strava", "zwift"), default=None, + parser.add_argument("--setup-intervals", action="store_true", help="Connect Intervals.icu or replace its API key") + parser.add_argument("--disconnect-intervals", action="store_true", help="Disconnect Intervals.icu") + parser.add_argument("--target", choices=("garmin", "strava", "zwift", "intervals"), default=None, help="Sync only one configured target") parser.add_argument("--select-profile", action="store_true", help="Choose which Eufy profile to sync") parser.add_argument("--update-password", action="store_true", help="Change stored passwords") @@ -277,6 +283,16 @@ def _main() -> None: maintenance._disconnect_zwift(config_path) return + if args.setup_intervals: + with _credential_lock("--setup-intervals"): + setup._setup_intervals(config_path) + return + + if args.disconnect_intervals: + with _credential_lock("--disconnect-intervals"): + maintenance._disconnect_intervals(config_path) + return + # Handle profile selection if args.select_profile: with _credential_lock("--select-profile"): @@ -447,6 +463,7 @@ def _main() -> None: "--update-password" in err or "changed your Eufy password" in err for _, err in failures ) + intervals_setup_needed = any("--setup-intervals" in err for _, err in failures) multiple_profiles = any("multiple Eufy profiles" in err for _, err in failures) all_transient = all(failure_notify.is_transient_network_error(err) for _, err in failures) completed = "" @@ -469,6 +486,14 @@ def _main() -> None: command="eufy-sync --update-password", ) failure_notify.clear_network_failures() + elif intervals_setup_needed: + platform_support.notify( + "eufy-sync: Intervals.icu key rejected", + (completed.strip() + " " if completed else "") + + "Run: eufy-sync --setup-intervals", + command="eufy-sync --setup-intervals", + ) + failure_notify.clear_network_failures() elif multiple_profiles: platform_support.notify( "eufy-sync: choose your profile", @@ -531,7 +556,9 @@ def _main() -> None: apps.append("Strava") if any(u.zwift for u in config.users): apps.append("Zwift") - print(f"You're all set! Check the {' and '.join(apps)} app to see your data.") + if any(u.intervals for u in config.users): + apps.append("Intervals.icu") + print(f"You're all set! Check the {_join_names(apps)} app to see your data.") elif not args.verbose: status._print_summary(total_counts, failures, state, config.users, report) diff --git a/eufy_sync/cli/doctor.py b/eufy_sync/cli/doctor.py index 49053be..0264bb3 100644 --- a/eufy_sync/cli/doctor.py +++ b/eufy_sync/cli/doctor.py @@ -19,6 +19,7 @@ from eufy_sync.credentials import active_store_label from eufy_sync.eufy_client import EufyClient from eufy_sync.garmin_client import GarminClient +from eufy_sync.intervals_client import IntervalsClient from eufy_sync.state import SyncState from eufy_sync.strava_client import StravaClient from eufy_sync.zwift_client import ZwiftClient @@ -73,6 +74,8 @@ def report(status: str, label: str, detail: str, fix: str | None = None) -> None targets.append("strava") if user.zwift: targets.append("zwift") + if user.intervals: + targets.append("intervals") report("PASS", "config", f"valid ({len(config.users)} user, targets: {', '.join(targets)})") # 2. profile @@ -96,6 +99,10 @@ def report(status: str, label: str, detail: str, fix: str | None = None) -> None if user.zwift: _check_zwift_session(report, user) + # 6c. intervals.icu API key + if user.intervals: + _check_intervals_key(report, user) + # 7. eufy cloud _check_eufy_cloud(report, eufy_client) @@ -219,11 +226,27 @@ def _check_zwift_session(report, user) -> None: client.close() +def _check_intervals_key(report, user) -> None: + client = None + try: + client = IntervalsClient(user.intervals) + client.authenticate() + client.check_connection() + report("PASS", "intervals key", f"works for athlete {user.intervals.athlete_id} (live read check)") + except Exception as e: + _report_connection_error(report, "intervals key", e) + finally: + if client is not None: + with suppress(Exception): + client.close() + + def _report_connection_error(report, label: str, error: Exception) -> None: msg = str(error) # Network and server failures should not tell the user to reset a login. fix = next((f"eufy-sync {hint}" for hint in ( "--update-password", "--reauth garmin", "--setup-strava", "--reauth zwift", + "--setup-intervals", ) if hint in msg), None) report("FAIL", label, msg, fix) diff --git a/eufy_sync/cli/maintenance.py b/eufy_sync/cli/maintenance.py index 3a10bdc..62b6f08 100644 --- a/eufy_sync/cli/maintenance.py +++ b/eufy_sync/cli/maintenance.py @@ -230,6 +230,27 @@ def _disconnect_zwift(config_path: Path) -> None: print("Zwift disconnected. Other sync targets are unchanged.") +def _disconnect_intervals(config_path: Path) -> None: + """Remove only the Intervals.icu configuration and its API key.""" + 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) + user = config["users"][0] + if "intervals" not in user: + print("Intervals.icu is not configured.") + return + + user_name = user.get("name", "default") + del user["intervals"] + shared._write_config(config_path, config) + + from eufy_sync.credentials import delete_password + delete_password(f"{user_name}:intervals") + print("Intervals.icu disconnected. Other sync targets are unchanged.") + + def _install_launch_agent() -> None: """Install the scheduled-sync agent for the current platform.""" platform_support.install_agent() @@ -324,7 +345,7 @@ def _uninstall(data_dir: Path, config_path: Path | None = None, db_path: Path | for name in user_names: # "strava" here is the API app's client secret, not an account # password; it moved into the vault alongside the other two. - for suffix in ["eufy", "garmin", "strava", "zwift"]: + for suffix in ["eufy", "garmin", "strava", "zwift", "intervals"]: delete_password(f"{name}:{suffix}") delete_token("eufy") delete_token("garmin") diff --git a/eufy_sync/cli/setup.py b/eufy_sync/cli/setup.py index 78248cf..bc65c0a 100644 --- a/eufy_sync/cli/setup.py +++ b/eufy_sync/cli/setup.py @@ -49,12 +49,14 @@ def _first_run_setup(config_path: Path) -> None: garmin_answer = input("Connect Garmin? [Y/n] ").strip() strava_answer = input("Connect Strava? [y/N] ").strip() zwift_answer = input("Connect experimental Zwift weight sync? [y/N] ").strip() + intervals_answer = input("Connect Intervals.icu? [y/N] ").strip() connect_garmin = not garmin_answer.lower().startswith("n") connect_strava = strava_answer.lower().startswith("y") connect_zwift = zwift_answer.lower().startswith("y") + connect_intervals = intervals_answer.lower().startswith("y") - if not connect_garmin and not connect_strava and not connect_zwift: - print("Error: You must configure at least one sync target (Garmin, Strava, or Zwift).") + if not connect_garmin and not connect_strava and not connect_zwift and not connect_intervals: + print("Error: You must configure at least one sync target (Garmin, Strava, Zwift, or Intervals.icu).") sys.exit(1) # Collect credentials only for the selected targets. @@ -87,6 +89,11 @@ def _first_run_setup(config_path: Path) -> None: user_config["garmin"] = {"email": garmin_email} if strava_config: user_config["strava"] = {"client_id": strava_config["client_id"]} + # Checked before Zwift, which saves its login as soon as it validates. + intervals_key = None + if connect_intervals: + intervals_key, athlete_id = _prompt_intervals_key(retry_command="eufy-sync") + user_config["intervals"] = {"athlete_id": athlete_id} if connect_zwift: _connect_zwift(user_config, retry_command="eufy-sync") @@ -97,6 +104,8 @@ def _first_run_setup(config_path: Path) -> None: from eufy_sync.credentials import active_store_label, store_password if strava_config: store_password(f"{user_name}:strava", strava_config["client_secret"]) + if intervals_key: + store_password(f"{user_name}:intervals", intervals_key) print(f"Passwords saved to the {active_store_label()}.") # On a shared account, pick the right person before the first sync. @@ -126,6 +135,8 @@ def _first_run_setup(config_path: Path) -> None: targets.append("Strava") if connect_zwift: targets.append("Zwift") + if connect_intervals: + targets.append("Intervals.icu") print(f"Saved. Running first sync to {' and '.join(targets)} (last 7 days)...") if garmin_email: print("Logging in to Garmin (a browser may open if the direct login is rate-limited).") @@ -201,7 +212,8 @@ def _setup_strava(config_path: Path) -> None: print("Strava connected! Future syncs will update both targets.") -def _zwift_setup_password(prompt: str) -> str: +def _secret_prompt(prompt: str) -> str: + """Read a secret without echoing it, or stop if the terminal would echo.""" with warnings.catch_warnings(): warnings.simplefilter("error", getpass.GetPassWarning) try: @@ -241,7 +253,7 @@ def _connect_zwift(user: dict, retry_command: str = "eufy-sync --setup-zwift") - if not email: print("Error: Zwift email is required.") sys.exit(1) - password = _zwift_setup_password("Zwift password: ") + password = _secret_prompt("Zwift password: ") if not password: print("Error: Zwift password is required.") sys.exit(1) @@ -281,7 +293,7 @@ def _setup_zwift(config_path: Path) -> None: if not eufy_email: print("Error: Eufy email is required.") sys.exit(1) - eufy_password = _zwift_setup_password("Eufy password: ") + eufy_password = _secret_prompt("Eufy password: ") if not eufy_password: print("Error: Eufy password is required.") sys.exit(1) @@ -315,6 +327,61 @@ def _setup_zwift(config_path: Path) -> None: print("Zwift connected. Experimental sync will update current weight only.") +def _prompt_intervals_key(retry_command: str = "eufy-sync --setup-intervals") -> tuple[str, str]: + """Ask for an Intervals.icu API key and check it with a read. + + Returns (api_key, athlete_id). The athlete id comes from the API ("0" in + the path means the key's own athlete), so nobody has to look it up. + Nothing is saved here; on failure this exits before the caller saves.""" + print("") + print(" Intervals.icu") + print(" Get your API key from Developer Settings, near the bottom of https://intervals.icu/settings.") + print(" Each sync sets weight, and body fat when the scale measured it, on that day's wellness record.") + print(" The key is stored in the credential store and is never written to config.yaml.") + print("") + api_key = _secret_prompt("Intervals.icu API key: ").strip() + if not api_key: + print("Error: The Intervals.icu API key is required.") + sys.exit(1) + + from eufy_sync.config import IntervalsConfig + from eufy_sync.intervals_client import OWN_ATHLETE, IntervalsClient + + client = IntervalsClient(IntervalsConfig(athlete_id=OWN_ATHLETE, api_key=api_key)) + try: + athlete_id = client.athlete_id() + except Exception as e: + print(f"Intervals.icu connection failed: {e}") + print(f"Nothing was enabled. Retry with: {retry_command}") + sys.exit(1) + finally: + with suppress(Exception): + client.close() + print(f"Intervals.icu key works for athlete {athlete_id}.") + return api_key, athlete_id + + +def _setup_intervals(config_path: Path) -> None: + """Add Intervals.icu, or replace its API key, after the key passes a read.""" + 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) + + user = config["users"][0] + user_name = user.get("name", "default") + api_key, athlete_id = _prompt_intervals_key() + + from eufy_sync.credentials import store_password + store_password(f"{user_name}:intervals", api_key) + # Only the athlete id belongs in the YAML; drop any key written there. + user["intervals"] = {"athlete_id": athlete_id} + shared._write_config(config_path, config) + print("Intervals.icu connected. Future syncs will update its wellness records.") + + def _migrate_config_passwords(config_path: Path) -> None: """One-time migration: move passwords from config.yaml to the credential store.""" from eufy_sync.credentials import store_password @@ -360,6 +427,13 @@ def _migrate_config_passwords(config_path: Path) -> None: del user["strava"]["client_secret"] changed = True + # A hand-written Intervals.icu API key gets the same treatment. + api_key = (user.get("intervals") or {}).get("api_key") + if api_key and not re.fullmatch(r"\$\{\w+\}", api_key): + store_password(f"{name}:intervals", api_key) + del user["intervals"]["api_key"] + changed = True + if changed: shared._write_config(config_path, config) print("Migrated passwords from config file to the credential store.") diff --git a/eufy_sync/cli/status.py b/eufy_sync/cli/status.py index cc03c4f..9d20df7 100644 --- a/eufy_sync/cli/status.py +++ b/eufy_sync/cli/status.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone -from eufy_sync.reporting import SyncReport, garmin_existing_note, update_counts_summary +from eufy_sync.reporting import SyncReport, display_name, garmin_existing_note, update_counts_summary def _zwift_token_status(config) -> dict: @@ -94,6 +94,11 @@ def _print_summary( else: parts.append("Zwift not connected") + if user.intervals: + # The config only loads with a saved key, and a key has no expiry to + # report; --doctor checks it against the API. + parts.append("Intervals.icu key saved") + print(" | ".join(parts)) print( "If you weighed in recently and it isn't here, open the Eufy app so it " @@ -112,7 +117,7 @@ def _retry_queue_line(state, user) -> str | None: total = sum(waiting.values()) if not total: return None - names = ", ".join(target.capitalize() for target in sorted(waiting)) + names = ", ".join(display_name(target) for target in sorted(waiting)) return f"{total} upload{'' if total == 1 else 's'} waiting to retry ({names})" @@ -180,6 +185,12 @@ def _show_status(state, users: list) -> None: else: print("Zwift auth: not connected - run: eufy-sync --reauth zwift") + if user.intervals: + print( + f"Intervals.icu auth: API key saved for athlete {user.intervals.athlete_id} " + "(check it with --doctor; replace it with --setup-intervals)" + ) + def _show_history(state, users: list, limit: int = 14) -> None: """Print recent sync history as a table.""" @@ -198,9 +209,10 @@ def _show_history(state, users: list, limit: int = 14) -> None: target_cols = sorted(all_targets) # Header + widths = {t: max(8, len(t)) for t in target_cols} header = f"{'Date':<12} {'Weight':<22}" for t in target_cols: - header += f" {t.capitalize():<8}" + header += f" {t.capitalize():<{widths[t]}}" print(header) print("-" * len(header)) @@ -215,7 +227,7 @@ def _show_history(state, users: list, limit: int = 14) -> None: row = f"{date_str:<12} {weight_str:<22}" for t in target_cols: mark = "✓" if t in entry["targets"] else "-" - row += f" {mark:<8}" + row += f" {mark:<{widths[t]}}" print(row) print("") diff --git a/eufy_sync/config.py b/eufy_sync/config.py index 6c504a2..3b0ec7d 100644 --- a/eufy_sync/config.py +++ b/eufy_sync/config.py @@ -33,6 +33,12 @@ class ZwiftConfig: password: str +@dataclass +class IntervalsConfig: + athlete_id: str + api_key: str + + @dataclass class UserConfig: name: str @@ -40,6 +46,7 @@ class UserConfig: garmin: GarminConfig | None = None strava: StravaConfig | None = None zwift: ZwiftConfig | None = None + intervals: IntervalsConfig | None = None @dataclass @@ -118,6 +125,27 @@ def _get_strava_secret(user_name: str, yaml_secret: str | None, migrate: bool = ) +def _get_intervals_key(user_name: str, yaml_key: str | None, migrate: bool = True) -> str: + """Resolve the Intervals.icu API key: credential store first, then a YAML + fallback (normally a ${VAR} reference on a headless machine). + + Like the Strava secret, the fix is its own setup command rather than + --update-password, which only handles account passwords.""" + from eufy_sync.credentials import get_password + + stored = get_password(f"{user_name}:intervals", migrate=migrate) + if stored: + return stored + + if yaml_key: + return yaml_key + + raise ValueError( + f"No Intervals.icu API key found for user '{user_name}'. " + f"Run: eufy-sync --setup-intervals" + ) + + def load_config(path: Path, migrate: bool = True) -> AppConfig: """Parse the config and resolve its secrets. @@ -170,10 +198,24 @@ def load_config(path: Path, migrate: bool = True) -> AppConfig: password=_get_password(name, "zwift", u["zwift"]["email"], u["zwift"].get("password"), migrate), ) - if not garmin and not strava and not zwift: + intervals = None + if "intervals" in u: + section = u["intervals"] or {} + athlete_id = section.get("athlete_id") + if athlete_id is None or not str(athlete_id).strip(): + raise ValueError( + f"The intervals section for user '{name}' has no athlete_id. " + f"Run: eufy-sync --setup-intervals" + ) + intervals = IntervalsConfig( + athlete_id=str(athlete_id).strip(), + api_key=_get_intervals_key(name, section.get("api_key"), migrate), + ) + + if not garmin and not strava and not zwift and not intervals: raise ValueError( f"User '{name}' has no sync targets configured. " - f"Add a 'garmin', 'strava', and/or 'zwift' section to your config." + f"Add a 'garmin', 'strava', 'zwift', and/or 'intervals' section to your config." ) users.append(UserConfig( @@ -186,6 +228,7 @@ def load_config(path: Path, migrate: bool = True) -> AppConfig: garmin=garmin, strava=strava, zwift=zwift, + intervals=intervals, )) return AppConfig(users=users) diff --git a/eufy_sync/intervals_client.py b/eufy_sync/intervals_client.py new file mode 100644 index 0000000..98a78fe --- /dev/null +++ b/eufy_sync/intervals_client.py @@ -0,0 +1,141 @@ +"""Intervals.icu wellness sync through its official API. + +Authentication is HTTP Basic with the literal username "API_KEY" and the +athlete's personal API key (Settings > Developer Settings) as the password. +Intervals.icu keeps one wellness record per local date, and a PUT to +/api/v1/athlete/{id}/wellness/{date} changes only the fields it sends, so +re-sending a date is safe and the last value sent for that date stays. + +Only fields the published Wellness schema documents are sent: ``weight`` (kg) +and ``bodyFat`` (%). The schema has no muscle mass, bone mass, or BMR field, +and its ``hydration`` field is an integer rating rather than a percentage. +See https://intervals.icu/api/v1/docs and +https://forum.intervals.icu/t/api-access-to-intervals-icu/609. +""" +from __future__ import annotations + +import logging +import math +from datetime import date +from typing import TYPE_CHECKING, Any +from urllib.parse import quote + +import httpx + +if TYPE_CHECKING: + from eufy_sync.config import IntervalsConfig + +logger = logging.getLogger(__name__) + +API_BASE = "https://intervals.icu/api/v1" +API_USERNAME = "API_KEY" +# The API guide: "0" in an athlete-id path means the key's own athlete. +OWN_ATHLETE = "0" +SETUP_HINT = "Run: eufy-sync --setup-intervals" + + +def _permanent(message: str) -> Exception: + from eufy_sync.sync import PermanentSyncError + return PermanentSyncError(message) + + +def _raise_for_status(response: httpx.Response, action: str) -> None: + """Map an unsuccessful response to the sync error classes. + + 401/403: the key is wrong or revoked, or it does not belong to the + configured athlete; only setup fixes that. 429: the API's rate limit, + which will not clear within seconds, so stop for this run and let the + next scheduled run send it. Other 4xx: the request itself is refused and + a retry would send the same thing. 5xx: the service is having trouble, + so the normal backoff applies. + """ + status = response.status_code + if 200 <= status < 300: + return + logger.debug("Intervals.icu %s failed: HTTP %d", action, status) + if status in (401, 403): + raise _permanent(f"Intervals.icu rejected the API key (HTTP {status}). {SETUP_HINT}") + if status == 429: + from eufy_sync.sync import RetryNextRunError + raise RetryNextRunError( + f"Intervals.icu rate limit reached during {action} (HTTP 429); the next run tries again" + ) + if 400 <= status < 500: + raise _permanent(f"Intervals.icu refused the {action} (HTTP {status})") + raise RuntimeError(f"Temporary Intervals.icu failure during {action} (HTTP {status})") + + +class IntervalsClient: + """Writes weight and body fat to Intervals.icu wellness records.""" + + def __init__(self, config: "IntervalsConfig", transport: httpx.BaseTransport | None = None): + self.config = config + # transport exists for tests, which answer requests with a MockTransport. + self._client = httpx.Client( + base_url=API_BASE, + auth=httpx.BasicAuth(API_USERNAME, config.api_key), + headers={"Accept": "application/json"}, + timeout=30.0, + follow_redirects=False, + transport=transport, + ) + + def authenticate(self) -> None: + """Nothing to log in to: every request carries the key. Refuse an + empty key or athlete id here so the run reports setup, not a 401.""" + if not self.config.api_key or not self.config.athlete_id: + raise _permanent(f"Intervals.icu is missing its API key or athlete id. {SETUP_HINT}") + + def athlete_id(self, athlete: str = OWN_ATHLETE) -> str: + """Read the athlete profile and return its id (e.g. "i12345"). + + With the default "0" this resolves the key's own athlete, which is how + setup learns the id without asking for it.""" + response = self._client.get(f"/athlete/{quote(athlete, safe='')}/profile") + _raise_for_status(response, "profile read") + try: + body = response.json() + except ValueError as exc: + raise RuntimeError("Intervals.icu returned invalid JSON for the profile read") from exc + athlete_obj = body.get("athlete") if isinstance(body, dict) else None + found = athlete_obj.get("id") if isinstance(athlete_obj, dict) else None + if isinstance(found, int) and not isinstance(found, bool): + found = str(found) + if not isinstance(found, str) or not found: + raise RuntimeError("Intervals.icu profile did not include an athlete id") + return found + + def check_connection(self) -> None: + """Verify the key can read the configured athlete. A key that belongs + to another athlete is refused by the API or reads a different id.""" + found = self.athlete_id(self.config.athlete_id) + if found != self.config.athlete_id: + raise _permanent( + f"The Intervals.icu API key belongs to athlete {found}, not {self.config.athlete_id}. " + f"{SETUP_HINT}" + ) + + @staticmethod + def wellness_payload(weight_kg: float, body_fat_pct: float | None) -> dict[str, float]: + """The fields to send. A missing or non-finite body fat is left out, + so the PUT leaves any value already on that date untouched.""" + payload = {"weight": round(float(weight_kg), 2)} + if body_fat_pct is not None and math.isfinite(body_fat_pct): + payload["bodyFat"] = round(float(body_fat_pct), 1) + return payload + + def update_wellness(self, day: date, weight_kg: float, body_fat_pct: float | None = None) -> dict[str, Any]: + """Write one local date's weight (and body fat when known). + + Returns what was sent, not the response: the wellness record also + holds sleep, HRV, and other data this tool has no reason to keep.""" + payload = self.wellness_payload(weight_kg, body_fat_pct) + response = self._client.put( + f"/athlete/{quote(self.config.athlete_id, safe='')}/wellness/{day.isoformat()}", json=payload, + ) + _raise_for_status(response, "wellness update") + logger.info("Updated Intervals.icu wellness for %s: %s", day.isoformat(), payload) + return {"date": day.isoformat(), **payload} + + def close(self) -> None: + self._client.close() diff --git a/eufy_sync/reporting.py b/eufy_sync/reporting.py index 683e048..af9ec8c 100644 --- a/eufy_sync/reporting.py +++ b/eufy_sync/reporting.py @@ -4,6 +4,14 @@ from dataclasses import dataclass, field from datetime import date +# Target names whose plain capitalization is not the service's name. +_DISPLAY_NAMES = {"intervals": "Intervals.icu"} + + +def display_name(target: str) -> str: + """The service name a person reads, e.g. "Intervals.icu" for intervals.""" + return _DISPLAY_NAMES.get(target, target.capitalize()) + @dataclass class SyncReport: @@ -33,5 +41,5 @@ def garmin_existing_note(report: SyncReport | None) -> str: def update_counts_summary(counts: dict[str, int], *, planned: bool = False) -> str: """Describe per-target updates while preserving insertion order.""" label = "Syncs planned" if planned else "Syncs completed" - parts = [f"{name.capitalize()} {count}" for name, count in counts.items()] + parts = [f"{display_name(name)} {count}" for name, count in counts.items()] return f"{label}: {', '.join(parts)}." diff --git a/eufy_sync/state.py b/eufy_sync/state.py index 8185d1f..0d7a694 100644 --- a/eufy_sync/state.py +++ b/eufy_sync/state.py @@ -197,6 +197,29 @@ def weight_only_syncs_on_date(self, user_name: str, target: str, local_date: dat if datetime.fromisoformat(ts).astimezone().date() == local_date ] + def syncs_on_date(self, user_name: str, target: str, local_date: date) -> list[dict]: + """Every recorded sync to target for a local calendar date, as dicts + with measurement_id, measurement_timestamp (a datetime), weight_kg and + weight_only. Windowed in SQL, exact date check in Python, as in + has_synced_on_date.""" + lo, hi = _date_window(local_date) + cursor = self._conn.execute( + """SELECT eufy_measurement_id, measurement_timestamp, weight_kg, weight_only + FROM sync_log + WHERE user_name = ? AND target = ? + AND measurement_timestamp >= ? AND measurement_timestamp < ?""", + (user_name, target, lo, hi), + ) + rows = [] + for mid, ts, kg, weight_only in cursor.fetchall(): + taken = datetime.fromisoformat(ts) + if taken.astimezone().date() == local_date: + rows.append({ + "measurement_id": mid, "measurement_timestamp": taken, + "weight_kg": kg, "weight_only": bool(weight_only), + }) + return rows + def mark_upgraded(self, user_name: str, measurement_id: str, target: str) -> None: """Clear a sync's weight-only flag once its full body-comp record has replaced it in the target. The row stays, so the raw record itself diff --git a/eufy_sync/sync.py b/eufy_sync/sync.py index 4b45c0f..38eb69e 100644 --- a/eufy_sync/sync.py +++ b/eufy_sync/sync.py @@ -12,7 +12,7 @@ from eufy_sync import state as state_module from eufy_sync.config import UserConfig from eufy_sync.eufy_client import AmbiguousProfileError, EufyClient, EufyMeasurement -from eufy_sync.reporting import SyncReport +from eufy_sync.reporting import SyncReport, display_name from eufy_sync.state import SyncState from eufy_sync.transform import transform @@ -98,6 +98,62 @@ def _retry(fn, description: str): time.sleep(delay) +def _local_date(m: EufyMeasurement): + """The calendar date a weigh-in belongs to: the machine's local date, the + same convention as Garmin's same-date guard.""" + return m.timestamp.astimezone().date() + + +def _same_weigh_in(a_ts: datetime, a_kg: float, b_ts: datetime, b_kg: float) -> bool: + """Whether two records are close enough in time and weight to be the raw + and processed versions of one weigh-in (the issue #48 bounds).""" + return ( + abs((a_ts - b_ts).total_seconds()) <= UPGRADE_MAX_SECONDS + and abs(a_kg - b_kg) <= UPGRADE_MAX_WEIGHT_KG + ) + + +def _intervals_daily_picks(measurements: list[EufyMeasurement]) -> set[str]: + """Intervals.icu keeps one wellness record per date, so each local date + gets one measurement: its newest. When the newest is a raw weight-only + record and the processed record of the same weigh-in is also here, the + processed one is sent instead, since only it carries body fat.""" + by_date: dict = {} + for m in measurements: + by_date.setdefault(_local_date(m), []).append(m) + picks = set() + for day in by_date.values(): + newest = max(day, key=lambda m: m.timestamp) + if newest.weight_only: + full = [ + m for m in day + if not m.weight_only + and _same_weigh_in(m.timestamp, m.weight_kg, newest.timestamp, newest.weight_kg) + ] + if full: + newest = max(full, key=lambda m: m.timestamp) + picks.add(newest.measurement_id) + return picks + + +def _intervals_superseded( + measurement_id: str, timestamp: datetime, weight_kg: float, weight_only: bool, day_rows: list[dict], +) -> bool: + """Whether a newer weigh-in for the same date already reached + Intervals.icu. Sending this one would overwrite it with older values. + A raw weight-only record of the same weigh-in does not count: the + processed record replaces it and adds body fat.""" + for row in day_rows: + if row["measurement_id"] == measurement_id or row["measurement_timestamp"] <= timestamp: + continue + if row["weight_only"] and not weight_only and _same_weigh_in( + row["measurement_timestamp"], row["weight_kg"], timestamp, weight_kg, + ): + continue + return True + return False + + def _triage_retries( user_name: str, state: SyncState, target_names: list[str], dry_run: bool, ) -> tuple[set[tuple[str, str]], dict[tuple[str, str], bool]]: @@ -135,6 +191,17 @@ def _triage_retries( if not dry_run: state.clear_upload_retry(user_name, target, mid) continue + if target == "intervals": + # One record per date: a newer weigh-in that reached the same + # date already replaced this one there. The queue does not keep + # weight_only, so treat the entry as a full record; that only + # keeps it longer, never drops one that still matters. + taken = datetime.fromisoformat(row["measurement_timestamp"]) + day_rows = state.syncs_on_date(user_name, target, taken.astimezone().date()) + if _intervals_superseded(mid, taken, row["weight_kg"], False, day_rows): + if not dry_run: + state.clear_upload_retry(user_name, target, mid) + continue newer_success = row["last_newer_success_at"] target_healthy = ( newer_success is not None @@ -150,7 +217,7 @@ def _triage_retries( "%s the %s upload of %.2f kg from %s: it has failed for %d days (%d attempts) " "while newer weigh-ins uploaded, most recently at %s", "Would give up on" if dry_run else "Giving up on", - target.capitalize(), row["weight_kg"], row["measurement_timestamp"], + display_name(target), row["weight_kg"], row["measurement_timestamp"], int((now - first_failed) // 86400), row["attempts"], newer_success, ) elif row["attempts"] >= MAX_RETRY_ATTEMPTS or first_failed < oldest_allowed: @@ -172,10 +239,10 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No already calls synced (issue #58): the target can have lost data we recorded as delivered, and only the target knows that. """ - supported_targets = ("garmin", "strava", "zwift") + supported_targets = ("garmin", "strava", "zwift", "intervals") if target is not None and target not in supported_targets: raise ValueError( - f"Unknown sync target '{target}'. Choose garmin, strava, or zwift." + f"Unknown sync target '{target}'. Choose garmin, strava, zwift, or intervals." ) if target is not None and getattr(user, target) is None: raise ValueError( @@ -195,6 +262,9 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No if user.zwift and target in (None, "zwift"): from eufy_sync.zwift_client import ZwiftClient all_targets.append(("zwift", ZwiftClient(user.zwift))) + if user.intervals and target in (None, "intervals"): + from eufy_sync.intervals_client import IntervalsClient + all_targets.append(("intervals", IntervalsClient(user.intervals))) targets: list[tuple[str, object]] = [] @@ -245,7 +315,9 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No ts = state.get_latest_sync_timestamp(user.name, name) if ts is None: logger.info("No prior syncs to %s for %s, backfilling 7 days", name, user.name) - if name == "garmin": + # Per-date history targets reach back for a raw weigh-in still + # waiting for its processed record, and for queued failures. + if name in ("garmin", "intervals"): pending_ts = state.get_oldest_weight_only_timestamp( user.name, name, since=int(time.time()) - UPGRADE_LOOKBACK_DAYS * 86400, ) @@ -263,7 +335,7 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No logger.info( "Some queued %s uploads are older than %d days and still waiting; " "the regular fetch no longer reaches them (--backfill-days does)", - name.capitalize(), RETRY_ABANDON_DAYS + RETRY_REACH_BACK_MARGIN_DAYS, + display_name(name), RETRY_ABANDON_DAYS + RETRY_REACH_BACK_MARGIN_DAYS, ) retry_ts = floor ts = min(ts if ts is not None else default_cursor, retry_ts) @@ -319,6 +391,9 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No key=lambda m: m.timestamp, ) current_weight_latest = valid_fetched[-1] if valid_fetched else None + intervals_picks = _intervals_daily_picks( + [m for m in measurements if transform(m) is not None] + ) # Backfill can return older, unsynced readings alongside a newest # reading that dedup will skip, or omit the newest reading entirely. @@ -345,7 +420,7 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No target_measurement = current_weight_latest if target_name in current_weight_timestamps else m if target_measurement is None: continue - if target_name == "garmin" and body_comp is None: + if target_name in ("garmin", "intervals") and body_comp is None: logger.warning("Skipping invalid measurement: %s (%.1f kg)", m.measurement_id, m.weight_kg) continue if ( @@ -353,7 +428,7 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No and current_weight_timestamps[target_name] is not None and target_measurement.timestamp.timestamp() < current_weight_timestamps[target_name] ): - logger.debug("Skipping %s history older than its current weight: %s", target_name.capitalize(), target_measurement.measurement_id) + logger.debug("Skipping %s history older than its current weight: %s", display_name(target_name), target_measurement.measurement_id) continue if target_name in current_weight_timestamps and m.measurement_id != target_measurement.measurement_id: continue @@ -365,9 +440,27 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No and (target_name, target_measurement.measurement_id) in given_up and target_measurement.measurement_id not in pending ): - logger.debug("Skipping %s upload that was given up: %s", target_name.capitalize(), target_measurement.measurement_id) + logger.debug("Skipping %s upload that was given up: %s", display_name(target_name), target_measurement.measurement_id) continue + # Intervals.icu keeps one wellness record per date, and a PUT + # replaces that date's values. Send only the date's newest + # weigh-in, and never one older than what the date already + # holds from us. A processed record that replaces raw + # weight-only rows on its date resends even when its id is + # already recorded (issue #48: the ids can match). + intervals_replaces: list[str] = [] + if target_name == "intervals": + if m.measurement_id not in intervals_picks: + logger.debug("Skipping Intervals.icu for %s: a newer weigh-in that day is sent instead", m.measurement_id) + continue + day_rows = state.syncs_on_date(user.name, "intervals", _local_date(m)) + if _intervals_superseded(m.measurement_id, m.timestamp, m.weight_kg, m.weight_only, day_rows): + logger.debug("Skipping Intervals.icu for %s: a newer weigh-in for that date was already sent", m.measurement_id) + continue + if not m.weight_only: + intervals_replaces = [r["measurement_id"] for r in day_rows if r["weight_only"]] + # Still consulted in repair mode: it decides whether the sync # is recorded below, since re-uploading a known id must not # insert a second row (UNIQUE on user/measurement/target). @@ -411,7 +504,7 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No ] if len(matches) == 1: upgrade_row = matches[0] - elif synced_already and not repair: + elif synced_already and not repair and m.measurement_id not in intervals_replaces: logger.debug("Already synced to %s: %s", target_name, m.measurement_id) continue @@ -486,10 +579,17 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No lambda: client.upload_body_composition(body_comp), # noqa: B023 f"Garmin upload ({m.measurement_id})", ) + elif target_name == "intervals": + result = attempt( + lambda: client.update_wellness( # noqa: B023 + _local_date(m), body_comp.weight, body_comp.percent_fat, # noqa: B023 + ), + f"Intervals.icu wellness update ({m.measurement_id})", + ) else: result = attempt( lambda: client.update_weight(target_measurement.weight_kg), # noqa: B023 - f"{target_name.capitalize()} weight update ({target_measurement.measurement_id})", + f"{display_name(target_name)} weight update ({target_measurement.measurement_id})", ) response_str = json.dumps(result) if result else None @@ -509,6 +609,9 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No logger.info("Upgraded weight-only entry to full body comp for %s", m.timestamp.astimezone().date()) if target_name == "garmin" and (upgrade_row is not None or m.measurement_id in pending): state.clear_pending_upgrade(user.name, m.measurement_id) + for replaced_id in intervals_replaces: + # That date now holds this record's values. + state.mark_upgraded(user.name, replaced_id, "intervals") if target_name in current_weight_timestamps: state.clear_upload_retries_through(user.name, target_name, target_measurement.timestamp) else: @@ -520,7 +623,7 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No ) capped_errors.pop(target_name, None) except UnsupportedMeasurementError as e: - logger.warning("Skipping %s for %s: %s", target_name.capitalize(), user.name, e) + logger.warning("Skipping %s for %s: %s", display_name(target_name), user.name, e) continue except Exception as e: logger.error("Upload to %s failed for %s: %s", target_name, user.name, e) @@ -557,14 +660,19 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No ) logger.info( "%s will retry %s on the next run (failed %d time%s)", - target_name.capitalize(), target_measurement.measurement_id, attempts, "" if attempts == 1 else "s", + display_name(target_name), target_measurement.measurement_id, attempts, "" if attempts == 1 else "s", ) continue counts[target_name] += 1 lb = target_measurement.weight_kg * 2.20462 - detail = "full body comp" if target_name == "garmin" and not m.weight_only else "weight only" - logger.info("Synced %.2f kg (%.1f lb) → %s (%s)", target_measurement.weight_kg, lb, target_name.capitalize(), detail) + if target_name == "garmin" and not m.weight_only: + detail = "full body comp" + elif target_name == "intervals" and isinstance(result, dict) and "bodyFat" in result: + detail = "weight and body fat" + else: + detail = "weight only" + logger.info("Synced %.2f kg (%.1f lb) → %s (%s)", target_measurement.weight_kg, lb, display_name(target_name), detail) # Small delay between uploads to avoid rate limiting time.sleep(1 if target_name == "garmin" else 0.5) diff --git a/pyproject.toml b/pyproject.toml index f62842e..2b6c556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "eufy-sync" version = "1.15.0" -description = "Sync Eufy smart scale data to Garmin Connect, Strava, and Zwift" +description = "Sync Eufy smart scale data to Garmin Connect, Strava, Zwift, and Intervals.icu" readme = "README.md" license = "MIT" requires-python = ">=3.12" @@ -14,6 +14,7 @@ keywords = [ "garmin connect", "strava", "zwift", + "intervals.icu", "eufy", "eufylife", "smart scale", diff --git a/tests/test_cli.py b/tests/test_cli.py index 410eaaa..b5dd3f6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -704,7 +704,7 @@ def test_first_run_setup_keeps_the_strava_secret_out_of_the_yaml(_keyring, tmp_p from eufy_sync.credentials import get_password config_path = tmp_path / "config.yaml" - answers = ["e@example.com", "n", "y", "n", "12345", "sekrit"] + answers = ["e@example.com", "n", "y", "n", "n", "12345", "sekrit"] with patch("builtins.input", side_effect=answers), \ patch("getpass.getpass", return_value="eufy-pw"), \ @@ -725,7 +725,7 @@ def test_first_run_setup_allows_zwift_only(_keyring, tmp_path, capsys): config_path = tmp_path / "config.yaml" client = MagicMock() - answers = ["e@example.com", "n", "n", "y", "z@example.com"] + answers = ["e@example.com", "n", "n", "y", "n", "z@example.com"] with patch("sys.stdin.isatty", return_value=True), \ patch("builtins.input", side_effect=answers), \ @@ -752,7 +752,7 @@ def test_first_run_setup_allows_garmin_and_zwift(_keyring, tmp_path): config_path = tmp_path / "config.yaml" client = MagicMock() - answers = ["e@example.com", "y", "n", "y", "g@example.com", "z@example.com"] + answers = ["e@example.com", "y", "n", "y", "n", "g@example.com", "z@example.com"] with patch("sys.stdin.isatty", return_value=True), \ patch("builtins.input", side_effect=answers), \ @@ -777,7 +777,7 @@ def test_first_run_setup_allows_all_three_targets(_keyring, tmp_path): config_path = tmp_path / "config.yaml" client = MagicMock() answers = [ - "e@example.com", "y", "y", "y", "g@example.com", + "e@example.com", "y", "y", "y", "n", "g@example.com", "12345", "strava-secret", "z@example.com", ] @@ -802,7 +802,7 @@ def test_first_run_setup_rejects_no_targets_without_storing_credentials(_keyring from eufy_sync.credentials import get_password config_path = tmp_path / "config.yaml" - with patch("builtins.input", side_effect=["e@example.com", "n", "n", "n"]), \ + with patch("builtins.input", side_effect=["e@example.com", "n", "n", "n", "n"]), \ patch("getpass.getpass", return_value="eufy-pw"), \ pytest.raises(SystemExit): _first_run_setup(config_path) @@ -819,7 +819,7 @@ def test_first_run_setup_failed_zwift_validation_writes_nothing(_keyring, tmp_pa config_path = tmp_path / "config.yaml" client = MagicMock() client.authenticate.side_effect = RuntimeError("account rejected") - answers = ["e@example.com", "n", "n", "y", "z@example.com"] + answers = ["e@example.com", "n", "n", "y", "n", "z@example.com"] with patch("sys.stdin.isatty", return_value=True), \ patch("builtins.input", side_effect=answers), \ @@ -843,7 +843,7 @@ def test_zwift_only_first_run_syncs_and_offers_scheduler(_keyring, tmp_path): config_path = tmp_path / "config.yaml" db_path = tmp_path / "state.db" client = MagicMock() - answers = ["e@example.com", "n", "n", "y", "z@example.com"] + answers = ["e@example.com", "n", "n", "y", "n", "z@example.com"] argv = ["eufy-sync", "--config", str(config_path), "--db", str(db_path)] with patch("sys.argv", argv), \ diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9a94245..c6e2364 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -529,3 +529,63 @@ def test_garmin_and_strava_skipped_when_not_configured(tmp_path, monkeypatch, ca assert "garmin session" not in out assert "strava token" not in out assert code == 0 + + +def _with_intervals(monkeypatch): + from eufy_sync.config import IntervalsConfig + app_config = _app_config() + app_config.users[0].intervals = IntervalsConfig(athlete_id="i12345", api_key="the-key") + monkeypatch.setattr(doctor, "load_config", MagicMock(return_value=app_config)) + client = MagicMock() + monkeypatch.setattr(doctor, "IntervalsClient", MagicMock(return_value=client)) + return client + + +def test_intervals_key_passes_a_live_read(tmp_path, monkeypatch, capsys): + config_path, db_path = _patch_all_pass(tmp_path, monkeypatch) + client = _with_intervals(monkeypatch) + + assert doctor._run_doctor(config_path, db_path) == 0 + + out = capsys.readouterr().out + assert "targets: garmin, strava, intervals" in out + assert "PASS intervals key works for athlete i12345 (live read check)" in out + assert "the-key" not in out + client.authenticate.assert_called_once_with() + client.check_connection.assert_called_once_with() + client.close.assert_called_once_with() + + +def test_intervals_rejected_key_fails_with_setup_fix(tmp_path, monkeypatch, capsys): + config_path, db_path = _patch_all_pass(tmp_path, monkeypatch) + client = _with_intervals(monkeypatch) + client.check_connection.side_effect = RuntimeError( + "Intervals.icu rejected the API key (HTTP 401). Run: eufy-sync --setup-intervals" + ) + + assert doctor._run_doctor(config_path, db_path) == 1 + + out = capsys.readouterr().out + assert "FAIL intervals key" in out + assert "fix: eufy-sync --setup-intervals" in out + client.close.assert_called_once_with() + + +def test_intervals_network_failure_suggests_no_setup(tmp_path, monkeypatch, capsys): + config_path, db_path = _patch_all_pass(tmp_path, monkeypatch) + client = _with_intervals(monkeypatch) + client.check_connection.side_effect = RuntimeError("All connection attempts failed") + + assert doctor._run_doctor(config_path, db_path) == 1 + + out = capsys.readouterr().out + assert "FAIL intervals key" in out + assert "--setup-intervals" not in out + + +def test_intervals_check_skipped_when_not_configured(tmp_path, monkeypatch, capsys): + config_path, db_path = _patch_all_pass(tmp_path, monkeypatch) + monkeypatch.setattr(doctor, "IntervalsClient", MagicMock(side_effect=AssertionError("not configured"))) + + assert doctor._run_doctor(config_path, db_path) == 0 + assert "intervals" not in capsys.readouterr().out diff --git a/tests/test_intervals_client.py b/tests/test_intervals_client.py new file mode 100644 index 0000000..bdd5f94 --- /dev/null +++ b/tests/test_intervals_client.py @@ -0,0 +1,209 @@ +"""IntervalsClient against a mocked httpx transport. No request leaves the +machine: every response comes from the handler in each test.""" +from __future__ import annotations + +import base64 +import json +from datetime import date + +import httpx +import pytest + +from eufy_sync.config import IntervalsConfig +from eufy_sync.intervals_client import IntervalsClient +from eufy_sync.sync import PermanentSyncError, RetryNextRunError, _is_permanent, _retry + + +def _client(handler, athlete_id: str = "i12345", api_key: str = "test-key") -> IntervalsClient: + return IntervalsClient( + IntervalsConfig(athlete_id=athlete_id, api_key=api_key), + transport=httpx.MockTransport(handler), + ) + + +def _recorder(status: int = 200, body=None): + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(status, json=body if body is not None else {}) + + return seen, handler + + +def test_update_wellness_puts_weight_and_body_fat_to_the_local_date(): + seen, handler = _recorder(body={"id": "2026-09-30", "weight": 80.12, "bodyFat": 18.3, "hrv": 60}) + client = _client(handler) + + result = client.update_wellness(date(2026, 9, 30), 80.1234, 18.26) + + (request,) = seen + assert request.method == "PUT" + assert str(request.url) == "https://intervals.icu/api/v1/athlete/i12345/wellness/2026-09-30" + assert json.loads(request.content) == {"weight": 80.12, "bodyFat": 18.3} + # Only what was sent is kept, never the rest of the wellness record. + assert result == {"date": "2026-09-30", "weight": 80.12, "bodyFat": 18.3} + client.close() + + +def test_requests_use_basic_auth_with_the_api_key_username(): + seen, handler = _recorder() + client = _client(handler, api_key="secret-key") + + client.update_wellness(date(2026, 9, 30), 80.0) + + expected = base64.b64encode(b"API_KEY:secret-key").decode() + assert seen[0].headers["Authorization"] == f"Basic {expected}" + client.close() + + +@pytest.mark.parametrize("body_fat", [None, float("nan"), float("inf")]) +def test_missing_body_fat_is_left_out_of_the_payload(body_fat): + seen, handler = _recorder() + client = _client(handler) + + result = client.update_wellness(date(2026, 9, 30), 80.0, body_fat) + + assert json.loads(seen[0].content) == {"weight": 80.0} + assert "bodyFat" not in result + client.close() + + +def test_athlete_id_in_the_path_is_escaped(): + seen, handler = _recorder() + client = _client(handler, athlete_id="i1/../../x") + + client.update_wellness(date(2026, 9, 30), 80.0) + + assert seen[0].url.raw_path.decode() == "/api/v1/athlete/i1%2F..%2F..%2Fx/wellness/2026-09-30" + client.close() + + +@pytest.mark.parametrize("status", [401, 403]) +def test_rejected_key_is_permanent_and_names_setup(status): + _, handler = _recorder(status=status) + client = _client(handler) + + with pytest.raises(PermanentSyncError, match="--setup-intervals"): + client.update_wellness(date(2026, 9, 30), 80.0) + client.close() + + +def test_rate_limit_waits_for_the_next_run(): + calls, handler = _recorder(status=429) + client = _client(handler) + + with pytest.raises(RetryNextRunError, match="429"): + _retry(lambda: client.update_wellness(date(2026, 9, 30), 80.0), "test") + + # Not retried within the run, and still queued for the next one. + assert len(calls) == 1 + client.close() + + +def test_rate_limit_is_not_classified_permanent(): + _, handler = _recorder(status=429) + client = _client(handler) + with pytest.raises(RetryNextRunError) as exc: + client.update_wellness(date(2026, 9, 30), 80.0) + assert not _is_permanent(exc.value) + client.close() + + +@pytest.mark.parametrize("status", [400, 404, 422]) +def test_other_client_errors_are_permanent_without_setup_hint(status): + _, handler = _recorder(status=status) + client = _client(handler) + + with pytest.raises(PermanentSyncError) as exc: + client.update_wellness(date(2026, 9, 30), 80.0) + assert f"HTTP {status}" in str(exc.value) + assert "--setup-intervals" not in str(exc.value) + client.close() + + +@pytest.mark.parametrize("status", [500, 502, 503]) +def test_server_errors_are_retryable(status): + _, handler = _recorder(status=status) + client = _client(handler) + + with pytest.raises(RuntimeError) as exc: + client.update_wellness(date(2026, 9, 30), 80.0) + assert not _is_permanent(exc.value) + assert not isinstance(exc.value, RetryNextRunError) + client.close() + + +def test_network_errors_are_retryable(): + def handler(request): + raise httpx.ConnectError("All connection attempts failed", request=request) + + client = _client(handler) + with pytest.raises(httpx.ConnectError) as exc: + client.update_wellness(date(2026, 9, 30), 80.0) + assert not _is_permanent(exc.value) + client.close() + + +def test_athlete_id_resolves_the_keys_own_athlete(): + seen, handler = _recorder(body={"athlete": {"id": "i777", "name": "Someone"}}) + client = _client(handler, athlete_id="0") + + assert client.athlete_id() == "i777" + assert seen[0].method == "GET" + assert str(seen[0].url) == "https://intervals.icu/api/v1/athlete/0/profile" + client.close() + + +@pytest.mark.parametrize("body", [{}, {"athlete": {}}, {"athlete": {"id": ""}}, [], {"athlete": "i1"}]) +def test_profile_without_an_id_is_an_error(body): + _, handler = _recorder(body=body) + client = _client(handler) + with pytest.raises(RuntimeError, match="athlete id"): + client.athlete_id() + client.close() + + +def test_check_connection_reads_the_configured_athlete(): + seen, handler = _recorder(body={"athlete": {"id": "i12345"}}) + client = _client(handler) + + client.check_connection() + + assert str(seen[0].url) == "https://intervals.icu/api/v1/athlete/i12345/profile" + client.close() + + +def test_check_connection_refuses_a_key_for_another_athlete(): + _, handler = _recorder(body={"athlete": {"id": "i999"}}) + client = _client(handler) + + with pytest.raises(PermanentSyncError, match="i999.*--setup-intervals"): + client.check_connection() + client.close() + + +@pytest.mark.parametrize("status", [401, 403]) +def test_check_connection_rejected_key_names_setup(status): + _, handler = _recorder(status=status) + client = _client(handler) + with pytest.raises(PermanentSyncError, match="--setup-intervals"): + client.check_connection() + client.close() + + +def test_authenticate_refuses_an_empty_key_without_a_request(): + calls, handler = _recorder() + client = _client(handler, api_key="") + with pytest.raises(PermanentSyncError, match="--setup-intervals"): + client.authenticate() + assert calls == [] + client.close() + + +def test_authenticate_makes_no_request(): + calls, handler = _recorder() + client = _client(handler) + client.authenticate() + assert calls == [] + client.close() diff --git a/tests/test_intervals_sync.py b/tests/test_intervals_sync.py new file mode 100644 index 0000000..74ce1fd --- /dev/null +++ b/tests/test_intervals_sync.py @@ -0,0 +1,376 @@ +"""Intervals.icu as a per-date history target in sync_user: one wellness +record per local date, the newest weigh-in of a date wins, and failures go +through the same retry queue as Garmin.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from eufy_sync.config import EufyConfig, GarminConfig, IntervalsConfig, UserConfig +from eufy_sync.eufy_client import EufyMeasurement +from eufy_sync.state import SyncState +from eufy_sync.sync import PermanentSyncError, RetryNextRunError, sync_user + +# Local noon keeps every same-day offset below on one local date. +NOON = datetime.now().astimezone().replace(hour=12, minute=0, second=0, microsecond=0) + + +def _m(weight_kg: float, days_ago: int, minutes: int = 0, *, body_fat: float | None = 20.0, + weight_only: bool = False) -> EufyMeasurement: + dt = NOON - timedelta(days=days_ago) + timedelta(minutes=minutes) + return EufyMeasurement( + measurement_id=f"cust_{int(dt.timestamp())}", + customer_id="cust", + device_id="dev", + timestamp=dt, + weight_kg=weight_kg, + body_fat_pct=None if weight_only else body_fat, + weight_only=weight_only, + ) + + +def _user(garmin: bool = False) -> UserConfig: + return UserConfig( + name="default", + eufy=EufyConfig(email="e@example.com", password="pw"), + garmin=GarminConfig(email="g@example.com", password="pw") if garmin else None, + intervals=IntervalsConfig(athlete_id="i12345", api_key="test-key"), + ) + + +def _run(user, state, history, *, fail_weights=(), error=None, fetches=None, garmin=None, **kwargs): + """One run against a fake Eufy that honors the fetch cursor.""" + def fetch(after_timestamp=None): + if fetches is not None: + fetches.append(after_timestamp) + return [m for m in history if after_timestamp is None or m.timestamp.timestamp() >= after_timestamp] + + eufy = MagicMock() + eufy.fetch_measurements.side_effect = fetch + + def update(day, weight_kg, body_fat_pct=None): + if round(weight_kg, 2) in fail_weights: + raise error or RuntimeError("Temporary Intervals.icu failure during wellness update (HTTP 503)") + sent = {"date": day.isoformat(), "weight": round(weight_kg, 2)} + if body_fat_pct is not None: + sent["bodyFat"] = round(body_fat_pct, 1) + return sent + + intervals = MagicMock() + intervals.update_wellness.side_effect = update + if garmin is None: + garmin = MagicMock() + garmin.has_weight_on_date.return_value = False + garmin.upload_body_composition.return_value = {"ok": True} + + with patch("eufy_sync.sync.EufyClient", return_value=eufy), \ + patch("eufy_sync.intervals_client.IntervalsClient", return_value=intervals) as cls, \ + patch("eufy_sync.garmin_client.GarminClient", return_value=garmin), \ + patch("eufy_sync.sync.time.sleep"): + counts, errors = sync_user(user, state, headless=True, **kwargs) + return counts, errors, intervals, cls + + +def _sent(intervals) -> list[tuple[str, float, float | None]]: + return [ + (c.args[0].isoformat(), round(c.args[1], 2), c.args[2]) + for c in intervals.update_wellness.call_args_list + ] + + +def _day(m: EufyMeasurement) -> str: + return m.timestamp.astimezone().date().isoformat() + + +def _rows(state): + return {(r["target"], r["measurement_id"]): r for r in state.get_upload_retries("default")} + + +def test_each_date_gets_its_newest_weigh_in_with_body_fat(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + morning, evening, next_day = _m(80.0, 3, 0, body_fat=21.0), _m(80.6, 3, 120, body_fat=20.5), _m(79.9, 2) + + counts, errors, intervals, _ = _run(user, state, [next_day, evening, morning], backfill_days=7) + + assert errors == {} + assert counts == {"intervals": 2} + assert _sent(intervals) == [(_day(evening), 80.6, 20.5), (_day(next_day), 79.9, 20.0)] + assert state.is_synced("default", evening.measurement_id, "intervals") + assert not state.is_synced("default", morning.measurement_id, "intervals") + intervals.authenticate.assert_called_once_with() + intervals.close.assert_called_once_with() + state.close() + + +def test_a_synced_date_is_not_sent_again(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + m = _m(80.0, 2) + _run(user, state, [m]) + + counts, errors, intervals, _ = _run(user, state, [m], backfill_days=7) + + assert errors == {} and counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + state.close() + + +def test_a_later_weigh_in_on_a_synced_date_replaces_it(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + first = _m(80.0, 2) + _run(user, state, [first]) + later = _m(80.4, 2, 90) + + counts, _, intervals, _ = _run(user, state, [first, later]) + + assert counts == {"intervals": 1} + assert _sent(intervals) == [(_day(later), 80.4, 20.0)] + state.close() + + +def test_backfill_never_overwrites_a_date_with_an_older_weigh_in(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + later = _m(80.4, 2, 90) + _run(user, state, [later]) + # An earlier reading of the same day turns up only now. + earlier = _m(81.0, 2) + + counts, errors, intervals, _ = _run(user, state, [earlier], backfill_days=7) + + assert errors == {} and counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + state.close() + + +def test_invalid_weights_are_never_sent(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + good, bad = _m(80.0, 2), _m(5.0, 1) + + counts, errors, intervals, _ = _run(user, state, [good, bad]) + + assert errors == {} and counts == {"intervals": 1} + assert _sent(intervals) == [(_day(good), 80.0, 20.0)] + state.close() + + +def test_processed_record_replaces_a_raw_weight_only_one(tmp_path: Path): + """Issue #48 for Intervals.icu: the raw weight lands first, the processed + record (a different id, seconds apart) later adds body fat.""" + state = SyncState(tmp_path / "s.db") + user = _user() + raw = _m(80.0, 2, 1, weight_only=True) + _run(user, state, [raw]) + full = _m(80.05, 2, 0, body_fat=19.5) + + counts, errors, intervals, _ = _run(user, state, [raw, full]) + + assert errors == {} and counts == {"intervals": 1} + assert _sent(intervals) == [(_day(full), 80.05, 19.5)] + assert state.weight_only_syncs_on_date("default", "intervals", full.timestamp.astimezone().date()) == [] + + _, _, intervals, _ = _run(user, state, [raw, full]) + intervals.update_wellness.assert_not_called() + state.close() + + +def test_processed_record_with_the_same_id_resends_once(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + raw = _m(80.0, 2, weight_only=True) + _run(user, state, [raw]) + full = _m(80.0, 2, body_fat=19.5) + assert full.measurement_id == raw.measurement_id + + counts, _, intervals, _ = _run(user, state, [full]) + assert counts == {"intervals": 1} + assert _sent(intervals) == [(_day(full), 80.0, 19.5)] + + counts, _, intervals, _ = _run(user, state, [full]) + assert counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + state.close() + + +def test_a_batch_prefers_the_processed_record_over_its_raw_twin(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + full = _m(80.0, 2, 0, body_fat=19.5) + raw = _m(80.0, 2, 1, weight_only=True) + + counts, _, intervals, _ = _run(user, state, [full, raw]) + + assert counts == {"intervals": 1} + assert _sent(intervals) == [(_day(full), 80.0, 19.5)] + state.close() + + +def test_retryable_failure_is_queued_and_replayed_next_run(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + m1, m2, m3 = _m(80.0, 3), _m(81.0, 2), _m(82.0, 1) + + counts, errors, intervals, _ = _run(user, state, [m1, m2, m3], fail_weights={81.0}) + assert counts == {"intervals": 1} and "503" in errors["intervals"] + assert [w for _, w, _ in _sent(intervals)] == [80.0, 81.0, 81.0, 81.0] # backoff, then stop + assert _rows(state)[("intervals", m2.measurement_id)]["attempts"] == 1 + + counts, errors, intervals, _ = _run(user, state, [m1, m2, m3]) + assert errors == {} + assert [w for _, w, _ in _sent(intervals)] == [81.0, 82.0] + assert _rows(state) == {} + state.close() + + +def test_rate_limit_stops_the_target_for_this_run_and_queues(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user(garmin=True) + m1, m2 = _m(80.0, 2), _m(81.0, 1) + error = RetryNextRunError("Intervals.icu rate limit reached during wellness update (HTTP 429)") + + counts, errors, intervals, _ = _run(user, state, [m1, m2], fail_weights={80.0}, error=error) + + assert [w for _, w, _ in _sent(intervals)] == [80.0] # no in-run retries + assert "429" in errors["intervals"] + assert counts == {"garmin": 2, "intervals": 0} # Garmin keeps going + assert ("intervals", m1.measurement_id) in _rows(state) + state.close() + + +def test_rejected_key_is_reported_and_not_queued(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + m1 = _m(80.0, 1) + error = PermanentSyncError("Intervals.icu rejected the API key (HTTP 401). Run: eufy-sync --setup-intervals") + + counts, errors, intervals, _ = _run(user, state, [m1], fail_weights={80.0}, error=error) + + assert "--setup-intervals" in errors["intervals"] + assert intervals.update_wellness.call_count == 1 + assert _rows(state) == {} + state.close() + + +def test_queued_entry_is_dropped_once_a_newer_weigh_in_reaches_its_date(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + earlier, later = _m(80.0, 2), _m(80.5, 2, 60) + state.record_upload_failure("default", "intervals", earlier.measurement_id, + earlier.timestamp.isoformat(), 80.0, NOON.isoformat()) + state.record_sync("default", later.measurement_id, later.timestamp.isoformat(), 80.5, + NOON.isoformat(), target="intervals") + + counts, errors, intervals, _ = _run(user, state, [earlier, later]) + + assert errors == {} and counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + assert _rows(state) == {} + state.close() + + +def test_cursor_reaches_back_for_a_queued_failure(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + old, newer = _m(80.0, 10), _m(81.0, 1) + state.record_sync("default", newer.measurement_id, newer.timestamp.isoformat(), 81.0, + NOON.isoformat(), target="intervals") + state.record_upload_failure("default", "intervals", old.measurement_id, + old.timestamp.isoformat(), 80.0, NOON.isoformat()) + fetches: list = [] + + counts, errors, intervals, _ = _run(user, state, [old, newer], fetches=fetches) + + assert fetches[0] <= old.timestamp.timestamp() + assert errors == {} and counts == {"intervals": 1} + assert _sent(intervals) == [(_day(old), 80.0, 20.0)] + state.close() + + +def test_cursor_reaches_back_for_a_raw_weigh_in_awaiting_its_processed_record(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + raw, newer = _m(80.0, 5, weight_only=True), _m(81.0, 1) + _run(user, state, [raw, newer]) + fetches: list = [] + + _run(user, state, [raw, newer], fetches=fetches) + + assert fetches[0] <= raw.timestamp.timestamp() - 60 + state.close() + + +def test_target_intervals_builds_only_that_client(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user(garmin=True) + garmin = MagicMock() + + with patch("eufy_sync.garmin_client.GarminClient", side_effect=AssertionError("not selected")): + counts, errors, intervals, _ = _run(user, state, [_m(80.0, 1)], target="intervals", garmin=garmin) + + assert counts == {"intervals": 1} and errors == {} + state.close() + + +def test_target_intervals_requires_it_configured(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = UserConfig(name="default", eufy=EufyConfig(email="e@example.com", password="pw"), + garmin=GarminConfig(email="g@example.com", password="pw")) + with pytest.raises(ValueError, match="not configured"): + sync_user(user, state, target="intervals") + state.close() + + +def test_dry_run_sends_nothing(tmp_path: Path, capsys): + state = SyncState(tmp_path / "s.db") + user = _user() + + counts, errors, intervals, _ = _run(user, state, [_m(80.0, 2), _m(80.5, 2, 60)], dry_run=True) + + assert counts == {"intervals": 1} + intervals.update_wellness.assert_not_called() + assert "Would sync to intervals: 80.5 kg" in capsys.readouterr().out + assert state.get_latest_sync_timestamp("default", "intervals") is None + state.close() + + +def test_repair_resends_a_synced_date(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + m = _m(80.0, 2) + _run(user, state, [m]) + + counts, _, intervals, _ = _run(user, state, [m], repair_days=7) + + assert counts == {"intervals": 1} + assert _sent(intervals) == [(_day(m), 80.0, 20.0)] + state.close() + + +def test_authentication_failure_leaves_garmin_running(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user(garmin=True) + intervals = MagicMock() + intervals.authenticate.side_effect = PermanentSyncError( + "Intervals.icu is missing its API key or athlete id. Run: eufy-sync --setup-intervals" + ) + garmin = MagicMock() + garmin.has_weight_on_date.return_value = False + garmin.upload_body_composition.return_value = {"ok": True} + eufy = MagicMock() + eufy.fetch_measurements.return_value = [_m(80.0, 1)] + + with patch("eufy_sync.sync.EufyClient", return_value=eufy), \ + patch("eufy_sync.intervals_client.IntervalsClient", return_value=intervals), \ + patch("eufy_sync.garmin_client.GarminClient", return_value=garmin), \ + patch("eufy_sync.sync.time.sleep"): + counts, errors = sync_user(user, state, headless=True) + + assert counts == {"garmin": 1} + assert "--setup-intervals" in errors["intervals"] + state.close() diff --git a/tests/test_lock.py b/tests/test_lock.py index 706848a..37638b7 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -237,6 +237,8 @@ def migrate(path): (["--setup-strava"], "eufy_sync.cli.setup._setup_strava", "--setup-strava"), (["--setup-zwift"], "eufy_sync.cli.setup._setup_zwift", "--setup-zwift"), (["--disconnect-zwift"], "eufy_sync.cli.maintenance._disconnect_zwift", "--disconnect-zwift"), + (["--setup-intervals"], "eufy_sync.cli.setup._setup_intervals", "--setup-intervals"), + (["--disconnect-intervals"], "eufy_sync.cli.maintenance._disconnect_intervals", "--disconnect-intervals"), (["--select-profile"], "eufy_sync.cli.profiles._select_profile", "--select-profile"), (["--update-password"], "eufy_sync.cli.maintenance._update_password", "--update-password"), (["--reauth"], "eufy_sync.cli.maintenance._reauth", "--reauth"), diff --git a/tests/test_setup_intervals.py b/tests/test_setup_intervals.py new file mode 100644 index 0000000..6617984 --- /dev/null +++ b/tests/test_setup_intervals.py @@ -0,0 +1,401 @@ +"""Connecting, configuring, reporting on, and disconnecting Intervals.icu. +The client is mocked throughout; no test reaches the real API.""" +from __future__ import annotations + +import getpass +import warnings +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from eufy_sync import credentials +from eufy_sync.cli.maintenance import _disconnect_intervals +from eufy_sync.cli.setup import _migrate_config_passwords, _setup_intervals +from eufy_sync.config import EufyConfig, IntervalsConfig, UserConfig, load_config +from eufy_sync.reporting import update_counts_summary + + +def _config(path, **extra): + user = {"name": "default", "eufy": {"email": "e@example.com"}, "garmin": {"email": "g@example.com"}} + user.update(extra) + path.write_text(yaml.safe_dump({"users": [user]})) + + +def _client(athlete_id="i12345"): + client = MagicMock() + client.athlete_id.return_value = athlete_id + return client + + +# --- --setup-intervals -------------------------------------------------------- + + +def test_setup_checks_the_key_then_saves_it_outside_the_yaml(tmp_path, capsys): + path = tmp_path / "config.yaml" + _config(path) + client = _client() + + with patch("eufy_sync.intervals_client.IntervalsClient", return_value=client) as cls, \ + patch("getpass.getpass", return_value=" the-key "), \ + patch("builtins.input", side_effect=AssertionError("the athlete id is looked up, not asked")): + _setup_intervals(path) + + config = cls.call_args.args[0] + assert config == IntervalsConfig(athlete_id="0", api_key="the-key") + client.athlete_id.assert_called_once_with() + client.close.assert_called_once_with() + saved = yaml.safe_load(path.read_text())["users"][0] + assert saved["intervals"] == {"athlete_id": "i12345"} + assert saved["garmin"] == {"email": "g@example.com"} + assert "the-key" not in path.read_text() + assert credentials.get_password("default:intervals") == "the-key" + out = capsys.readouterr().out + assert "athlete i12345" in out + assert "Intervals.icu connected" in out + + +def test_setup_replaces_a_key_and_drops_one_written_in_the_yaml(tmp_path): + path = tmp_path / "config.yaml" + _config(path, intervals={"athlete_id": "i1", "api_key": "old-plain-key"}) + credentials.store_password("default:intervals", "old-key") + + with patch("eufy_sync.intervals_client.IntervalsClient", return_value=_client("i1")), \ + patch("getpass.getpass", return_value="new-key"): + _setup_intervals(path) + + assert yaml.safe_load(path.read_text())["users"][0]["intervals"] == {"athlete_id": "i1"} + assert credentials.get_password("default:intervals") == "new-key" + + +def test_failed_check_saves_nothing_and_keeps_the_working_key(tmp_path, capsys): + path = tmp_path / "config.yaml" + _config(path, intervals={"athlete_id": "i1"}) + credentials.store_password("default:intervals", "working-key") + before = path.read_text() + client = _client() + client.athlete_id.side_effect = RuntimeError( + "Intervals.icu rejected the API key (HTTP 401). Run: eufy-sync --setup-intervals" + ) + + with patch("eufy_sync.intervals_client.IntervalsClient", return_value=client), \ + patch("getpass.getpass", return_value="typo-key"), \ + pytest.raises(SystemExit) as exc: + _setup_intervals(path) + + assert exc.value.code == 1 + assert path.read_text() == before + assert credentials.get_password("default:intervals") == "working-key" + client.close.assert_called_once_with() + out = capsys.readouterr().out + assert "Nothing was enabled. Retry with: eufy-sync --setup-intervals" in out + + +def test_setup_requires_a_key(tmp_path): + path = tmp_path / "config.yaml" + _config(path) + with patch("eufy_sync.intervals_client.IntervalsClient", side_effect=AssertionError("no check without a key")), \ + patch("getpass.getpass", return_value=" "), \ + pytest.raises(SystemExit): + _setup_intervals(path) + assert "intervals" not in yaml.safe_load(path.read_text())["users"][0] + + +def test_setup_needs_an_existing_config(tmp_path, capsys): + with pytest.raises(SystemExit): + _setup_intervals(tmp_path / "missing.yaml") + assert "Run eufy-sync first" in capsys.readouterr().out + + +def test_setup_refuses_a_key_prompt_that_would_echo(tmp_path): + path = tmp_path / "config.yaml" + _config(path) + + def unsafe_prompt(_): + warnings.warn("Password may echo", getpass.GetPassWarning, stacklevel=2) + raise AssertionError("must stop before reading an exposed key") + + with patch("getpass.getpass", side_effect=unsafe_prompt), pytest.raises(SystemExit): + _setup_intervals(path) + + +# --- --disconnect-intervals ----------------------------------------------------- + + +def test_disconnect_removes_only_intervals(tmp_path, capsys): + path = tmp_path / "config.yaml" + _config(path, intervals={"athlete_id": "i1"}) + credentials.store_password("default:intervals", "the-key") + credentials.store_password("default:garmin", "garmin-pw") + + _disconnect_intervals(path) + + user = yaml.safe_load(path.read_text())["users"][0] + assert "intervals" not in user + assert user["garmin"] == {"email": "g@example.com"} + assert credentials.get_password("default:intervals") is None + assert credentials.get_password("default:garmin") == "garmin-pw" + assert "Intervals.icu disconnected" in capsys.readouterr().out + + +def test_disconnect_when_not_configured_changes_nothing(tmp_path, capsys): + path = tmp_path / "config.yaml" + _config(path) + before = path.read_text() + _disconnect_intervals(path) + assert path.read_text() == before + assert "not configured" in capsys.readouterr().out + + +# --- first run ------------------------------------------------------------------ + + +@patch("eufy_sync.credentials._keyring_available", return_value=False) +def test_first_run_can_choose_intervals_alone(_keyring, tmp_path, capsys): + from eufy_sync.cli.setup import _first_run_setup + + path = tmp_path / "config.yaml" + answers = ["e@example.com", "n", "n", "n", "y"] + + with patch("builtins.input", side_effect=answers), \ + patch("getpass.getpass", side_effect=["eufy-pw", "the-key"]), \ + patch("eufy_sync.intervals_client.IntervalsClient", return_value=_client()), \ + patch("eufy_sync.eufy_client.EufyClient", side_effect=RuntimeError("offline")): + _first_run_setup(path) + + user = yaml.safe_load(path.read_text())["users"][0] + assert user["intervals"] == {"athlete_id": "i12345"} + assert "garmin" not in user + assert "the-key" not in path.read_text() + assert credentials.get_password("default:intervals") == "the-key" + assert credentials.get_password("default:eufy") == "eufy-pw" + assert "Running first sync to Intervals.icu" in capsys.readouterr().out + + +@patch("eufy_sync.credentials._keyring_available", return_value=False) +def test_first_run_failed_intervals_check_writes_nothing(_keyring, tmp_path, capsys): + from eufy_sync.cli.setup import _first_run_setup + + path = tmp_path / "config.yaml" + client = _client() + client.athlete_id.side_effect = RuntimeError("Intervals.icu rejected the API key (HTTP 403)") + + with patch("builtins.input", side_effect=["e@example.com", "y", "n", "n", "y", "g@example.com"]), \ + patch("getpass.getpass", side_effect=["eufy-pw", "garmin-pw", "bad-key"]), \ + patch("eufy_sync.intervals_client.IntervalsClient", return_value=client), \ + pytest.raises(SystemExit): + _first_run_setup(path) + + assert not path.exists() + assert credentials.get_password("default:eufy") is None + assert credentials.get_password("default:intervals") is None + assert "Retry with: eufy-sync\n" in capsys.readouterr().out + + +# --- config and migration ------------------------------------------------------- + + +@patch("eufy_sync.credentials._keyring_available", return_value=False) +def test_config_reads_the_key_from_the_credential_store(_keyring, tmp_path): + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump({"users": [{ + "name": "default", "eufy": {"email": "e@example.com"}, "intervals": {"athlete_id": "i12345"}, + }]})) + credentials.store_password("default:eufy", "eufy-pw") + credentials.store_password("default:intervals", "the-key") + + user = load_config(path).users[0] + + assert user.intervals == IntervalsConfig(athlete_id="i12345", api_key="the-key") + assert user.garmin is None + + +@patch("eufy_sync.credentials._keyring_available", return_value=False) +def test_config_without_a_saved_key_points_at_setup(_keyring, tmp_path): + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump({"users": [{ + "name": "default", "eufy": {"email": "e@example.com"}, "intervals": {"athlete_id": "i12345"}, + }]})) + credentials.store_password("default:eufy", "eufy-pw") + + with pytest.raises(ValueError, match="--setup-intervals"): + load_config(path, migrate=False) + + +@patch("eufy_sync.credentials._keyring_available", return_value=False) +@pytest.mark.parametrize("section", [{}, None, {"athlete_id": " "}]) +def test_config_without_an_athlete_id_points_at_setup(_keyring, tmp_path, section): + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump({"users": [{ + "name": "default", "eufy": {"email": "e@example.com"}, "intervals": section, + }]})) + credentials.store_password("default:eufy", "eufy-pw") + credentials.store_password("default:intervals", "the-key") + + with pytest.raises(ValueError, match="athlete_id.*--setup-intervals"): + load_config(path) + + +@patch("eufy_sync.credentials._keyring_available", return_value=False) +def test_config_accepts_an_env_reference_for_the_key(_keyring, tmp_path, monkeypatch): + monkeypatch.setenv("INTERVALS_KEY", "env-key") + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump({"users": [{ + "name": "default", "eufy": {"email": "e@example.com"}, + "intervals": {"athlete_id": "i12345", "api_key": "${INTERVALS_KEY}"}, + }]})) + credentials.store_password("default:eufy", "eufy-pw") + + assert load_config(path).users[0].intervals.api_key == "env-key" + + +@patch("eufy_sync.credentials._keyring_available", return_value=False) +def test_migration_moves_a_plain_key_and_leaves_an_env_reference(_keyring, tmp_path): + plain = tmp_path / "plain.yaml" + _config(plain, intervals={"athlete_id": "i1", "api_key": "plain-key"}) + _migrate_config_passwords(plain) + assert yaml.safe_load(plain.read_text())["users"][0]["intervals"] == {"athlete_id": "i1"} + assert credentials.get_password("default:intervals") == "plain-key" + + env = tmp_path / "env.yaml" + _config(env, intervals={"athlete_id": "i1", "api_key": "${INTERVALS_KEY}"}) + _migrate_config_passwords(env) + assert yaml.safe_load(env.read_text())["users"][0]["intervals"]["api_key"] == "${INTERVALS_KEY}" + + +# --- status, summary, notifications --------------------------------------------- + + +def _intervals_user(): + return UserConfig( + name="default", + eufy=EufyConfig(email="e@example.com", password="pw"), + intervals=IntervalsConfig(athlete_id="i12345", api_key="the-key"), + ) + + +def test_status_shows_the_intervals_line_without_the_key(tmp_path, capsys): + from eufy_sync.cli.status import _show_status + from eufy_sync.state import SyncState + + state = SyncState(tmp_path / "s.db") + now = datetime.now(timezone.utc).isoformat() + state.record_upload_failure("default", "intervals", "a", now, 80.0, now) + with patch("eufy_sync.eufy_client.EufyClient") as eufy: + eufy.return_value.token_status.return_value = {"state": "valid", "days_remaining": 10} + _show_status(state, [_intervals_user()]) + + out = capsys.readouterr().out + assert "Intervals.icu auth: API key saved for athlete i12345" in out + assert "1 upload waiting to retry (Intervals.icu)" in out + assert "the-key" not in out + state.close() + + +def test_no_op_summary_mentions_intervals(tmp_path, capsys): + from eufy_sync.cli.status import _print_summary + from eufy_sync.state import SyncState + + state = SyncState(tmp_path / "s.db") + with patch("eufy_sync.eufy_client.EufyClient") as eufy: + eufy.return_value.token_status.return_value = {"state": "valid", "days_remaining": 10} + _print_summary({"intervals": 0}, [], state, [_intervals_user()]) + + assert "Intervals.icu key saved" in capsys.readouterr().out + state.close() + + +def test_counts_and_labels_use_the_service_name(): + from eufy_sync.cli.app import _target_label + + assert update_counts_summary({"garmin": 1, "intervals": 2}) == "Syncs completed: Garmin 1, Intervals.icu 2." + assert _target_label({"garmin": 1, "strava": 1, "intervals": 1}) == "Garmin, Strava and Intervals.icu" + + +def test_history_column_fits_the_intervals_name(tmp_path, capsys): + from eufy_sync.cli.status import _show_history + from eufy_sync.state import SyncState + + state = SyncState(tmp_path / "s.db") + ts = datetime(2026, 9, 30, 12, tzinfo=timezone.utc).isoformat() + state.record_sync("default", "m1", ts, 80.0, ts, target="garmin") + state.record_sync("default", "m1", ts, 80.0, ts, target="intervals") + _show_history(state, [_intervals_user()]) + + header, _, row = capsys.readouterr().out.splitlines()[:3] + assert header.index("Intervals") == row.rindex("✓") + state.close() + + +@patch("eufy_sync.cli.status._print_summary") +@patch("eufy_sync.platform_support.notify") +@patch("eufy_sync.cli.updater._check_for_updates") +@patch("eufy_sync.cli.setup._show_upgrade_notice") +@patch("eufy_sync.cli.setup._migrate_config_passwords") +@patch("eufy_sync.credentials._keyring_available", return_value=False) +def test_rejected_key_notification_names_the_setup_command( + _keyring, _migrate, _notice, _updates, mock_notify, _summary, tmp_path, +): + from eufy_sync.cli.app import main + + config_path = tmp_path / "config.yaml" + _config(config_path, intervals={"athlete_id": "i1"}) + credentials.store_password("default:eufy", "pw") + credentials.store_password("default:garmin", "pw") + credentials.store_password("default:intervals", "the-key") + argv = ["eufy-sync", "--config", str(config_path), "--db", str(tmp_path / "s.db"), "--headless"] + errors = {"intervals": "Intervals.icu rejected the API key (HTTP 401). Run: eufy-sync --setup-intervals"} + + with patch("eufy_sync.sync.sync_user", return_value=({"garmin": 1, "intervals": 0}, errors)), \ + patch("sys.argv", argv), \ + pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 1 + mock_notify.assert_called_once() + title, body = mock_notify.call_args.args + assert title == "eufy-sync: Intervals.icu key rejected" + assert body == "Synced to Garmin. Run: eufy-sync --setup-intervals" + assert mock_notify.call_args.kwargs["command"] == "eufy-sync --setup-intervals" + + +@patch("eufy_sync.cli.status._print_summary") +@patch("eufy_sync.cli.updater._check_for_updates") +@patch("eufy_sync.cli.setup._show_upgrade_notice") +@patch("eufy_sync.cli.setup._migrate_config_passwords") +@patch("eufy_sync.credentials._keyring_available", return_value=False) +def test_target_flag_accepts_intervals(_keyring, _migrate, _notice, _updates, _summary, tmp_path): + from eufy_sync.cli.app import main + + config_path = tmp_path / "config.yaml" + _config(config_path, intervals={"athlete_id": "i1"}) + credentials.store_password("default:eufy", "pw") + credentials.store_password("default:garmin", "pw") + credentials.store_password("default:intervals", "the-key") + argv = ["eufy-sync", "--config", str(config_path), "--db", str(tmp_path / "s.db"), "--target", "intervals"] + + with patch("eufy_sync.sync.sync_user", return_value=({"intervals": 1}, {})) as sync_user, \ + patch("sys.argv", argv), \ + pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 0 + assert sync_user.call_args.kwargs["target"] == "intervals" + + +def test_uninstall_removes_the_intervals_key(tmp_path): + from eufy_sync.cli.maintenance import _uninstall + + data_dir = tmp_path / "data" + data_dir.mkdir() + + with patch("sys.stdin.isatty", return_value=True), \ + patch("builtins.input", return_value="y"), \ + patch("eufy_sync.credentials._keyring_available", return_value=True), \ + patch("eufy_sync.credentials.delete_password") as delete_password, \ + patch("eufy_sync.credentials.delete_token"), \ + patch("eufy_sync.platform_support.agent_installed", return_value=False): + assert _uninstall(data_dir) is True + + delete_password.assert_any_call("default:intervals") diff --git a/tests/test_zwift_sync.py b/tests/test_zwift_sync.py index 5de3cf7..5422d48 100644 --- a/tests/test_zwift_sync.py +++ b/tests/test_zwift_sync.py @@ -239,7 +239,7 @@ def test_target_zwift_never_constructs_or_authenticates_other_services(tmp_path: @pytest.mark.parametrize( ("target", "message"), [ - ("fitbit", "Unknown sync target 'fitbit'. Choose garmin, strava, or zwift."), + ("fitbit", "Unknown sync target 'fitbit'. Choose garmin, strava, zwift, or intervals."), ("garmin", "Sync target 'garmin' is not configured for user 'default'."), ], ) From faef5053a2c4913989db0de648260e4789ca64d2 Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 2 Oct 2026 11:31:02 -0400 Subject: [PATCH 2/5] Document Intervals.icu setup and sync behavior --- README.md | 24 ++++++++++++++++++------ docs/command-reference.md | 8 +++++--- docs/headless-linux.md | 2 +- docs/security-and-troubleshooting.md | 10 +++++----- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e08f44c..cd76995 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Python](https://img.shields.io/pypi/pyversions/eufy-sync) ![License](https://img.shields.io/badge/license-MIT-green) -Syncs body composition from a Eufy smart scale to Garmin Connect, plus current weight to Strava and Zwift. +Syncs body composition from a Eufy smart scale to Garmin Connect, daily weight and body fat to Intervals.icu, and current weight to Strava and Zwift. > macOS, Windows, and headless Linux. Needs Python 3.12+ and a terminal. @@ -17,10 +17,11 @@ Syncs body composition from a Eufy smart scale to Garmin Connect, plus current w | Garmin Connect | Full body composition: weight, body fat, muscle mass, bone mass, hydration, BMR, visceral fat, metabolic age | | Strava | Current weight | | Zwift (experimental, opt-in) | Current weight, verified after saving | +| Intervals.icu | Weight and body fat on each day's wellness record | ## Install -You need a Eufy scale with cloud sync and an account with at least one target: Garmin Connect, Strava, or Zwift. +You need a Eufy scale with cloud sync and an account with at least one target: Garmin Connect, Strava, Zwift, or Intervals.icu. Each installer block below can be pasted in whole; its lines run one after another. @@ -66,9 +67,9 @@ Open a new terminal window so it picks up the newly installed command, then run: eufy-sync ``` -Setup asks for your Eufy login, then lets you choose Garmin Connect, Strava, and/or Zwift. Pick any combination, including Zwift alone. It asks for the credentials each selected service needs, lets you choose your profile on a shared Eufy account, and runs the first sync. Zwift is opt-in and marked experimental. +Setup asks for your Eufy login, then lets you choose Garmin Connect, Strava, Zwift, and/or Intervals.icu. Pick any combination, including a single target. It asks for the credentials each selected service needs, lets you choose your profile on a shared Eufy account, and runs the first sync. Zwift is opt-in and marked experimental. -If you want to add another target later, follow [Adding Strava](#adding-strava) or [Adding Zwift](#adding-zwift-experimental). +If you want to add another target later, follow [Adding Strava](#adding-strava), [Adding Intervals.icu](#adding-intervalsicu), or [Adding Zwift](#adding-zwift-experimental). > **Cloned the repo?** Run install commands from outside the repo directory to avoid path conflicts, e.g. `cd /tmp && pipx install eufy-sync`. @@ -106,13 +107,24 @@ Strava requires an active subscription to create a new API application. This is 3. Run `eufy-sync --setup-strava` and enter the Client ID and Secret. 4. Authorize eufy-sync in the browser when it opens. +## Adding Intervals.icu + +1. Open and find **Developer Settings** near the bottom. Generate an API key there if you don't have one. +2. Run `eufy-sync --setup-intervals` and paste the key. + +Setup reads your athlete profile with the key before saving anything, so a mistyped key changes nothing. It looks up your athlete ID on its own. The key goes into the credential store; `config.yaml` holds only the athlete ID. + +Intervals.icu keeps one wellness record per day. Each sync sets that day's weight, plus body fat when the scale measured it. If you weigh in twice in one day, the later weigh-in wins. The day is your computer's local date, the same date Garmin gets. Sleep, HRV, and the other wellness fields are left alone. Intervals.icu has no fields for muscle mass, bone mass, or the rest of Garmin's body composition, so those stay in Garmin. + +The first sync looks back seven days. To fill in older days, run `eufy-sync --target intervals --backfill-days 30`. If Intervals.icu stops accepting the key, a notification asks you to run `eufy-sync --setup-intervals` again. `eufy-sync --disconnect-intervals` removes this target and its key. + ## Adding Zwift (experimental) To add Zwift to an existing installation, run `eufy-sync --setup-zwift` and enter your Zwift email and password. Setup checks that it can read your Zwift profile before enabling sync, then saves the login in the existing credential store. Later runs reuse the saved login and renew the session automatically. Run `eufy-sync` to sync, or enable [automatic sync](#automatic-sync). Zwift receives only the newest valid weight for your selected Eufy profile, not body composition or weight history. Each update reads your current profile, changes its weight, then reads it again to verify the save. If the weight already matches, the tool records a verified match without sending another update. -This uses an unofficial Zwift route and is experimental. It has been verified on a real account, but that does not establish reliability across every account or future Zwift changes. Zwift failures are reported separately so Garmin and Strava can continue. +This uses an unofficial Zwift route and is experimental. It has been verified on a real account, but that does not establish reliability across every account or future Zwift changes. Zwift failures are reported separately so the other targets can continue. Use `eufy-sync --target zwift --dry-run` to preview a run. The first sync looks back seven days; if your latest weigh-in is older, use `eufy-sync --target zwift --backfill-days 30`. Older backfill cannot replace a newer weight already recorded as synced. `eufy-sync --disconnect-zwift` removes this target and its saved login from the installation. @@ -122,7 +134,7 @@ There is no telemetry. Credentials go over HTTPS only to the service they belong Eufy can send a raw Wi-Fi weight to its cloud before the phone app processes the full body composition. If only weight syncs, open the Eufy app, wait for it to process the weigh-in, then run `eufy-sync` again. -Garmin, Eufy, and Zwift use unofficial APIs here; the Strava integration uses its official API. Any of these services can change. Headless login renewal is designed to recover automatically, but cannot be guaranteed to keep working after a service changes its login flow. +Garmin, Eufy, and Zwift use unofficial APIs here; Strava and Intervals.icu use their official APIs. Any of these services can change. Headless login renewal is designed to recover automatically, but cannot be guaranteed to keep working after a service changes its login flow. ## Tests diff --git a/docs/command-reference.md b/docs/command-reference.md index 6d2e2ea..129a6b2 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -20,14 +20,16 @@ eufy-sync --version # show the installed version eufy-sync --setup-strava # add or reconnect Strava eufy-sync --setup-zwift # add experimental Zwift weight sync eufy-sync --disconnect-zwift # remove Zwift and its saved login -eufy-sync --target garmin # sync one target (garmin, strava, or zwift) +eufy-sync --setup-intervals # add Intervals.icu, or replace its API key +eufy-sync --disconnect-intervals # remove Intervals.icu and its API key +eufy-sync --target garmin # sync one target (garmin, strava, zwift, or intervals) eufy-sync --select-profile # choose a profile on a shared Eufy account eufy-sync --reauth # log back into every configured target eufy-sync --reauth garmin # log back into one target (garmin, strava, or zwift) eufy-sync --update-password # change stored Eufy, Garmin, or Zwift passwords ``` -On a fresh installation, run `eufy-sync` and choose any combination of Garmin, Strava, and experimental Zwift. The separate `--setup-zwift` command also supports a fresh Zwift-only installation, but does not offer automatic scheduling; run `eufy-sync --install-agent` afterward on macOS or Windows if needed. +On a fresh installation, run `eufy-sync` and choose any combination of Garmin, Strava, experimental Zwift, and Intervals.icu. `--setup-intervals` checks the key with a read before saving it; to change the key later, run it again. Intervals.icu has no `--reauth` or `--update-password` step: an API key has no login session to renew. The separate `--setup-zwift` command also supports a fresh Zwift-only installation, but does not offer automatic scheduling; run `eufy-sync --install-agent` afterward on macOS or Windows if needed. ## Automation and storage @@ -52,7 +54,7 @@ eufy-sync --repair-days 30 # resend Garmin history even when recorded as del `--repair-days` is for Garmin history that was deleted from Garmin Connect or filed under the wrong date by eufy-sync versions before 1.9.0. It resends Garmin measurements in the window even when the local database says they were delivered. It still leaves alone dates eufy-sync never uploaded when Garmin already holds data from another source. -Delete wrong-dated entries in Garmin Connect before repair. eufy-sync cannot delete them, so otherwise they remain beside the corrected entries. Strava and Zwift store current weight rather than history; in repair mode they receive only the newest eligible current weight. +Delete wrong-dated entries in Garmin Connect before repair. eufy-sync cannot delete them, so otherwise they remain beside the corrected entries. Strava and Zwift store current weight rather than history; in repair mode they receive only the newest eligible current weight. Intervals.icu keeps one record per day, so repair resends each day's newest weigh-in. `--repair-days` and `--backfill-days` cannot be used together. Both can be limited to one target with `--target` and previewed with `--dry-run`. diff --git a/docs/headless-linux.md b/docs/headless-linux.md index 583c9df..2baeb85 100644 --- a/docs/headless-linux.md +++ b/docs/headless-linux.md @@ -12,7 +12,7 @@ source $HOME/.local/bin/env uv tool install eufy-sync ``` -Run `eufy-sync` once in an interactive SSH session. Setup lets you choose Garmin, Strava, and/or experimental Zwift. Garmin login and any two-factor code work in the terminal. +Run `eufy-sync` once in an interactive SSH session. Setup lets you choose Garmin, Strava, experimental Zwift, and/or Intervals.icu. Garmin login and any two-factor code work in the terminal. For Strava, forward the authorization callback port when connecting from your computer. Replace `user@server` with your SSH login: diff --git a/docs/security-and-troubleshooting.md b/docs/security-and-troubleshooting.md index c9fe80b..03b7cd0 100644 --- a/docs/security-and-troubleshooting.md +++ b/docs/security-and-troubleshooting.md @@ -10,7 +10,7 @@ It checks configuration, credentials, target authorization, the local database, ## Credential storage -Credentials travel over HTTPS to their respective services: Eufy, Garmin, Strava, and Zwift. They are never logged or sent elsewhere. The only other outbound request is a weekly version check to `pypi.org`, without credentials. +Credentials travel over HTTPS to their respective services: Eufy, Garmin, Strava, Zwift, and Intervals.icu. They are never logged or sent elsewhere. The only other outbound request is a weekly version check to `pypi.org`, without credentials. Passwords and OAuth tokens are stored in one place: @@ -20,7 +20,7 @@ Passwords and OAuth tokens are stored in one place: The keychain is used whenever it works. Systems without one fall back to the file automatically. `eufy-sync --use-file-store` moves existing credentials to the file and keeps using it; `eufy-sync --use-keychain` moves them back. A credentials file that was not adopted with `--use-file-store` does not override a working keychain. On Windows, file fallback relies on the user profile's permissions because Windows does not honor POSIX file modes. -`~/.garmin-sync/config.yaml` contains email addresses, the selected Eufy profile ID, and the public Strava client ID. It is written with `600` permissions on systems that support POSIX modes. Passwords and the Strava client secret remain in the credential store. +`~/.garmin-sync/config.yaml` contains email addresses, the selected Eufy profile ID, the public Strava client ID, and the Intervals.icu athlete ID. It is written with `600` permissions on systems that support POSIX modes. Passwords, the Strava client secret, and the Intervals.icu API key remain in the credential store. ## Garmin login recovery @@ -81,9 +81,9 @@ The complete timer setup is in [Headless Linux](headless-linux.md). Eufy Cloud -> eufy_client.py -> transform -> garmin_client.py -> Garmin (body comp) (pull) (auth) (filter, -> strava_client.py -> Strava (weight) dedup, -> zwift_client.py -> Zwift (weight) - state.db) + state.db) -> intervals_client.py -> Intervals.icu (weight, body fat per day) ``` -Each run pulls Eufy history and checks the local SQLite database for what each target has already received. Garmin gets new full body-composition records through python-garminconnect's upload API. Dates Garmin already holds are skipped, which helps avoid duplicates when two machines sync the same account. Strava and Zwift receive the latest eligible current weight. Successful deliveries are recorded in the database; Zwift is recorded only after eufy-sync reads the profile again and verifies the saved weight. +Each run pulls Eufy history and checks the local SQLite database for what each target has already received. Garmin gets new full body-composition records through python-garminconnect's upload API. Dates Garmin already holds are skipped, which helps avoid duplicates when two machines sync the same account. Strava and Zwift receive the latest eligible current weight. Intervals.icu gets each day's newest weigh-in written to that day's wellness record, which replaces anything eufy-sync sent for the same day earlier. Successful deliveries are recorded in the database; Zwift is recorded only after eufy-sync reads the profile again and verifies the saved weight. -Garmin, Eufy, and Zwift use unofficial APIs in this project. Strava uses its official API. Changes to any service can require an eufy-sync update. +Garmin, Eufy, and Zwift use unofficial APIs in this project. Strava and Intervals.icu use their official APIs. Changes to any service can require an eufy-sync update. From b29cf729a0cb9a778aaa360772452cb5af6bf14c Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 2 Oct 2026 11:44:36 -0400 Subject: [PATCH 3/5] Send each Intervals.icu date its desired state instead of per measurement --- README.md | 2 +- docs/security-and-troubleshooting.md | 2 +- eufy_sync/intervals_client.py | 20 +- eufy_sync/intervals_plan.py | 165 ++++++++++++++++ eufy_sync/state.py | 60 ++++-- eufy_sync/sync.py | 282 +++++++++++++++++---------- tests/test_intervals_plan.py | 67 +++++++ tests/test_intervals_sync.py | 145 +++++++++++++- 8 files changed, 598 insertions(+), 145 deletions(-) create mode 100644 eufy_sync/intervals_plan.py create mode 100644 tests/test_intervals_plan.py diff --git a/README.md b/README.md index cd76995..1900a55 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Strava requires an active subscription to create a new API application. This is Setup reads your athlete profile with the key before saving anything, so a mistyped key changes nothing. It looks up your athlete ID on its own. The key goes into the credential store; `config.yaml` holds only the athlete ID. -Intervals.icu keeps one wellness record per day. Each sync sets that day's weight, plus body fat when the scale measured it. If you weigh in twice in one day, the later weigh-in wins. The day is your computer's local date, the same date Garmin gets. Sleep, HRV, and the other wellness fields are left alone. Intervals.icu has no fields for muscle mass, bone mass, or the rest of Garmin's body composition, so those stay in Garmin. +Intervals.icu keeps one wellness record per day. Each sync sets that day's weight, plus body fat when the scale measured it. If you weigh in twice in one day, the later weigh-in wins, whichever order Eufy returns them in. A day is sent again only when its values change. The day is your computer's local date, the same date Garmin gets. In the rare case that Eufy's raw and processed records of one weigh-in fall on either side of midnight, the weigh-in counts for the earlier day. Sleep, HRV, and the other wellness fields are left alone. Intervals.icu has no fields for muscle mass, bone mass, or the rest of Garmin's body composition, so those stay in Garmin. The first sync looks back seven days. To fill in older days, run `eufy-sync --target intervals --backfill-days 30`. If Intervals.icu stops accepting the key, a notification asks you to run `eufy-sync --setup-intervals` again. `eufy-sync --disconnect-intervals` removes this target and its key. diff --git a/docs/security-and-troubleshooting.md b/docs/security-and-troubleshooting.md index 03b7cd0..f56f92a 100644 --- a/docs/security-and-troubleshooting.md +++ b/docs/security-and-troubleshooting.md @@ -84,6 +84,6 @@ Eufy Cloud -> eufy_client.py -> transform -> garmin_client.py -> Garmin state.db) -> intervals_client.py -> Intervals.icu (weight, body fat per day) ``` -Each run pulls Eufy history and checks the local SQLite database for what each target has already received. Garmin gets new full body-composition records through python-garminconnect's upload API. Dates Garmin already holds are skipped, which helps avoid duplicates when two machines sync the same account. Strava and Zwift receive the latest eligible current weight. Intervals.icu gets each day's newest weigh-in written to that day's wellness record, which replaces anything eufy-sync sent for the same day earlier. Successful deliveries are recorded in the database; Zwift is recorded only after eufy-sync reads the profile again and verifies the saved weight. +Each run pulls Eufy history and checks the local SQLite database for what each target has already received. Garmin gets new full body-composition records through python-garminconnect's upload API. Dates Garmin already holds are skipped, which helps avoid duplicates when two machines sync the same account. Strava and Zwift receive the latest eligible current weight. Intervals.icu gets each day's newest weigh-in written to that day's wellness record, and a day is sent again only when those values change. The database keeps what was last sent to each day. Successful deliveries are recorded in the database; Zwift is recorded only after eufy-sync reads the profile again and verifies the saved weight. Garmin, Eufy, and Zwift use unofficial APIs in this project. Strava and Intervals.icu use their official APIs. Changes to any service can require an eufy-sync update. diff --git a/eufy_sync/intervals_client.py b/eufy_sync/intervals_client.py index 98a78fe..576f2cd 100644 --- a/eufy_sync/intervals_client.py +++ b/eufy_sync/intervals_client.py @@ -65,6 +65,15 @@ def _raise_for_status(response: httpx.Response, action: str) -> None: raise RuntimeError(f"Temporary Intervals.icu failure during {action} (HTTP {status})") +def wellness_payload(weight_kg: float, body_fat_pct: float | None) -> dict[str, float]: + """The fields to send. A missing or non-finite body fat is left out, + so the PUT leaves any value already on that date untouched.""" + payload = {"weight": round(float(weight_kg), 2)} + if body_fat_pct is not None and math.isfinite(body_fat_pct): + payload["bodyFat"] = round(float(body_fat_pct), 1) + return payload + + class IntervalsClient: """Writes weight and body fat to Intervals.icu wellness records.""" @@ -115,21 +124,12 @@ def check_connection(self) -> None: f"{SETUP_HINT}" ) - @staticmethod - def wellness_payload(weight_kg: float, body_fat_pct: float | None) -> dict[str, float]: - """The fields to send. A missing or non-finite body fat is left out, - so the PUT leaves any value already on that date untouched.""" - payload = {"weight": round(float(weight_kg), 2)} - if body_fat_pct is not None and math.isfinite(body_fat_pct): - payload["bodyFat"] = round(float(body_fat_pct), 1) - return payload - def update_wellness(self, day: date, weight_kg: float, body_fat_pct: float | None = None) -> dict[str, Any]: """Write one local date's weight (and body fat when known). Returns what was sent, not the response: the wellness record also holds sleep, HRV, and other data this tool has no reason to keep.""" - payload = self.wellness_payload(weight_kg, body_fat_pct) + payload = wellness_payload(weight_kg, body_fat_pct) response = self._client.put( f"/athlete/{quote(self.config.athlete_id, safe='')}/wellness/{day.isoformat()}", json=payload, ) diff --git a/eufy_sync/intervals_plan.py b/eufy_sync/intervals_plan.py new file mode 100644 index 0000000..0b57d38 --- /dev/null +++ b/eufy_sync/intervals_plan.py @@ -0,0 +1,165 @@ +"""What each Intervals.icu wellness date should hold. + +Intervals.icu keeps one wellness record per date, and a PUT replaces the +fields it sends. So rather than sending measurements one by one, which made +the result depend on the order they were fetched in, a run works out the +desired values for every date it touches and sends a date only when those +differ from what was last sent there. + +The desired values for a date come from every valid measurement known for +it: the ones fetched this run plus the weigh-in last sent to that date. A raw +Wi-Fi reading and its processed record (the same id, or within the issue #48 +bounds of 120 s and 0.1 kg) are one weigh-in, and the processed values win +because only they carry body fat. The newest weigh-in of a date wins. + +A weigh-in whose raw and processed records fall on either side of midnight +belongs to the date of its earliest record, which is when the person stepped +on the scale. Dates are the machine's local date, as for Garmin. + +Pure functions only: no I/O, no clients, so every case is cheap to test. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime + +from eufy_sync.intervals_client import wellness_payload + +# Same bounds as sync.UPGRADE_MAX_SECONDS / UPGRADE_MAX_WEIGHT_KG; repeated +# here so this module does not import sync. +SAME_WEIGH_IN_SECONDS = 120 +SAME_WEIGH_IN_KG = 0.1 + + +@dataclass(frozen=True) +class Reading: + """One Eufy record as far as Intervals.icu cares: weight, body fat when + the scale processed it, and whether it is a raw weight-only reading.""" + + measurement_id: str + timestamp: datetime + weight_kg: float + body_fat_pct: float | None + weight_only: bool + fetched: bool = True + + def to_json(self) -> dict: + return { + "id": self.measurement_id, "ts": self.timestamp.isoformat(), + "kg": self.weight_kg, "bf": self.body_fat_pct, "raw": self.weight_only, + } + + @classmethod + def from_json(cls, value: dict) -> "Reading": + return cls( + measurement_id=value["id"], timestamp=datetime.fromisoformat(value["ts"]), + weight_kg=value["kg"], body_fat_pct=value.get("bf"), weight_only=bool(value.get("raw")), + fetched=False, + ) + + +@dataclass +class WeighIn: + readings: list[Reading] = field(default_factory=list) + + @property + def started(self) -> datetime: + return min(r.timestamp for r in self.readings) + + @property + def local_date(self) -> date: + return self.started.astimezone().date() + + @property + def source(self) -> Reading: + """The reading whose values are sent: the newest processed one, or + the newest raw one when nothing processed has arrived.""" + processed = [r for r in self.readings if not r.weight_only] + return max(processed or self.readings, key=lambda r: r.timestamp) + + @property + def payload(self) -> dict[str, float]: + source = self.source + return wellness_payload(source.weight_kg, source.body_fat_pct) + + @property + def fetched(self) -> bool: + return any(r.fetched for r in self.readings) + + +def _pairs(a: Reading, b: Reading) -> bool: + if a.measurement_id == b.measurement_id: + return True + return ( + a.weight_only != b.weight_only + and abs((a.timestamp - b.timestamp).total_seconds()) <= SAME_WEIGH_IN_SECONDS + and abs(a.weight_kg - b.weight_kg) <= SAME_WEIGH_IN_KG + ) + + +def group_weigh_ins(readings: list[Reading]) -> list[WeighIn]: + """Collapse raw/processed pairs (and repeats of one id) into weigh-ins. + + The same reading can arrive twice (fetched, and stored with the last + sent date); the fetched copy is kept. Union-find keeps chains together + regardless of the order the readings came in.""" + unique: dict[tuple[str, str, bool], Reading] = {} + for r in readings: + key = (r.measurement_id, r.timestamp.isoformat(), r.weight_only) + if key not in unique or r.fetched: + unique[key] = r + items = sorted(unique.values(), key=lambda r: (r.timestamp, r.measurement_id)) + + parent = list(range(len(items))) + + def find(i: int) -> int: + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + by_id: dict[str, int] = {} + for i, r in enumerate(items): + if r.measurement_id in by_id: + parent[find(i)] = find(by_id[r.measurement_id]) + else: + by_id[r.measurement_id] = i + # Sorted by time, so only the readings just before can be in range. + j = i - 1 + while j >= 0 and (r.timestamp - items[j].timestamp).total_seconds() <= SAME_WEIGH_IN_SECONDS: + if _pairs(items[j], r): + parent[find(i)] = find(j) + j -= 1 + + groups: dict[int, WeighIn] = {} + for i, r in enumerate(items): + groups.setdefault(find(i), WeighIn()).readings.append(r) + return sorted(groups.values(), key=lambda w: w.started) + + +def desired_by_date(readings: list[Reading]) -> dict[date, WeighIn]: + """The weigh-in each date should hold, for dates with a fetched reading. + + Dates whose only knowledge is what was already sent are left out: this + run learned nothing new about them.""" + weigh_ins = group_weigh_ins(readings) + touched = {w.local_date for w in weigh_ins if w.fetched} + desired: dict[date, WeighIn] = {} + for w in weigh_ins: + day = w.local_date + if day in touched and (day not in desired or w.started > desired[day].started): + desired[day] = w + return desired + + +def same_weigh_in(queued_ts: datetime, queued_kg: float, queued_id: str, weigh_in: WeighIn) -> bool: + """Whether a queued retry (which records only id, time and weight) is one + of the readings in weigh_in.""" + return any( + r.measurement_id == queued_id + or ( + abs((r.timestamp - queued_ts).total_seconds()) <= SAME_WEIGH_IN_SECONDS + and abs(r.weight_kg - queued_kg) <= SAME_WEIGH_IN_KG + ) + for r in weigh_in.readings + ) diff --git a/eufy_sync/state.py b/eufy_sync/state.py index 0d7a694..a6ed2e3 100644 --- a/eufy_sync/state.py +++ b/eufy_sync/state.py @@ -61,6 +61,17 @@ def _init_db(self) -> None: last_newer_success_at TEXT, PRIMARY KEY(user_name, target, measurement_id) ); + -- What eufy-sync last sent to each Intervals.icu wellness date: + -- the fields sent and the readings that made up that weigh-in. + -- New in 1.16; CREATE IF NOT EXISTS is the whole migration. + CREATE TABLE IF NOT EXISTS intervals_days ( + user_name TEXT NOT NULL, + local_date TEXT NOT NULL, + payload_json TEXT NOT NULL, + readings_json TEXT NOT NULL, + sent_at TEXT NOT NULL, + PRIMARY KEY(user_name, local_date) + ); """) self._conn.commit() self._migrate_retry_newer_success_column() @@ -197,28 +208,37 @@ def weight_only_syncs_on_date(self, user_name: str, target: str, local_date: dat if datetime.fromisoformat(ts).astimezone().date() == local_date ] - def syncs_on_date(self, user_name: str, target: str, local_date: date) -> list[dict]: - """Every recorded sync to target for a local calendar date, as dicts - with measurement_id, measurement_timestamp (a datetime), weight_kg and - weight_only. Windowed in SQL, exact date check in Python, as in - has_synced_on_date.""" - lo, hi = _date_window(local_date) + def get_intervals_days(self, user_name: str, dates) -> dict[date, dict]: + """Last-sent record per local date, for the dates asked about. Each + value has payload (the fields sent) and readings (their JSON form, + see intervals_plan.Reading).""" + keys = sorted({d.isoformat() for d in dates}) + if not keys: + return {} + placeholders = ",".join("?" for _ in keys) cursor = self._conn.execute( - """SELECT eufy_measurement_id, measurement_timestamp, weight_kg, weight_only - FROM sync_log - WHERE user_name = ? AND target = ? - AND measurement_timestamp >= ? AND measurement_timestamp < ?""", - (user_name, target, lo, hi), + f"SELECT local_date, payload_json, readings_json FROM intervals_days" + f" WHERE user_name = ? AND local_date IN ({placeholders})", + (user_name, *keys), ) - rows = [] - for mid, ts, kg, weight_only in cursor.fetchall(): - taken = datetime.fromisoformat(ts) - if taken.astimezone().date() == local_date: - rows.append({ - "measurement_id": mid, "measurement_timestamp": taken, - "weight_kg": kg, "weight_only": bool(weight_only), - }) - return rows + return { + date.fromisoformat(day): {"payload": json.loads(payload), "readings": json.loads(readings)} + for day, payload, readings in cursor.fetchall() + } + + def record_intervals_day( + self, user_name: str, local_date: date, payload: dict, readings: list[dict], sent_at: str, + ) -> None: + with self._conn: + self._conn.execute( + """INSERT INTO intervals_days (user_name, local_date, payload_json, readings_json, sent_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_name, local_date) DO UPDATE SET + payload_json = excluded.payload_json, + readings_json = excluded.readings_json, + sent_at = excluded.sent_at""", + (user_name, local_date.isoformat(), json.dumps(payload), json.dumps(readings), sent_at), + ) def mark_upgraded(self, user_name: str, measurement_id: str, target: str) -> None: """Clear a sync's weight-only flag once its full body-comp record has diff --git a/eufy_sync/sync.py b/eufy_sync/sync.py index 38eb69e..8a29572 100644 --- a/eufy_sync/sync.py +++ b/eufy_sync/sync.py @@ -98,62 +98,6 @@ def _retry(fn, description: str): time.sleep(delay) -def _local_date(m: EufyMeasurement): - """The calendar date a weigh-in belongs to: the machine's local date, the - same convention as Garmin's same-date guard.""" - return m.timestamp.astimezone().date() - - -def _same_weigh_in(a_ts: datetime, a_kg: float, b_ts: datetime, b_kg: float) -> bool: - """Whether two records are close enough in time and weight to be the raw - and processed versions of one weigh-in (the issue #48 bounds).""" - return ( - abs((a_ts - b_ts).total_seconds()) <= UPGRADE_MAX_SECONDS - and abs(a_kg - b_kg) <= UPGRADE_MAX_WEIGHT_KG - ) - - -def _intervals_daily_picks(measurements: list[EufyMeasurement]) -> set[str]: - """Intervals.icu keeps one wellness record per date, so each local date - gets one measurement: its newest. When the newest is a raw weight-only - record and the processed record of the same weigh-in is also here, the - processed one is sent instead, since only it carries body fat.""" - by_date: dict = {} - for m in measurements: - by_date.setdefault(_local_date(m), []).append(m) - picks = set() - for day in by_date.values(): - newest = max(day, key=lambda m: m.timestamp) - if newest.weight_only: - full = [ - m for m in day - if not m.weight_only - and _same_weigh_in(m.timestamp, m.weight_kg, newest.timestamp, newest.weight_kg) - ] - if full: - newest = max(full, key=lambda m: m.timestamp) - picks.add(newest.measurement_id) - return picks - - -def _intervals_superseded( - measurement_id: str, timestamp: datetime, weight_kg: float, weight_only: bool, day_rows: list[dict], -) -> bool: - """Whether a newer weigh-in for the same date already reached - Intervals.icu. Sending this one would overwrite it with older values. - A raw weight-only record of the same weigh-in does not count: the - processed record replaces it and adds body fat.""" - for row in day_rows: - if row["measurement_id"] == measurement_id or row["measurement_timestamp"] <= timestamp: - continue - if row["weight_only"] and not weight_only and _same_weigh_in( - row["measurement_timestamp"], row["weight_kg"], timestamp, weight_kg, - ): - continue - return True - return False - - def _triage_retries( user_name: str, state: SyncState, target_names: list[str], dry_run: bool, ) -> tuple[set[tuple[str, str]], dict[tuple[str, str], bool]]: @@ -178,7 +122,9 @@ def _triage_retries( target, mid = row["target"], row["measurement_id"] if target not in target_names: continue - if state.is_synced(user_name, mid, target): + # Intervals.icu tracks delivery per date (intervals_days), and its + # queued id can match a raw reading already in sync_log. + if target != "intervals" and state.is_synced(user_name, mid, target): if not dry_run: state.clear_upload_retry(user_name, target, mid) continue @@ -191,17 +137,11 @@ def _triage_retries( if not dry_run: state.clear_upload_retry(user_name, target, mid) continue - if target == "intervals": - # One record per date: a newer weigh-in that reached the same - # date already replaced this one there. The queue does not keep - # weight_only, so treat the entry as a full record; that only - # keeps it longer, never drops one that still matters. - taken = datetime.fromisoformat(row["measurement_timestamp"]) - day_rows = state.syncs_on_date(user_name, target, taken.astimezone().date()) - if _intervals_superseded(mid, taken, row["weight_kg"], False, day_rows): - if not dry_run: - state.clear_upload_retry(user_name, target, mid) - continue + if target == "intervals" and _intervals_retry_superseded(user_name, state, row): + # A newer weigh-in already holds that date. + if not dry_run: + state.clear_upload_retry(user_name, target, mid) + continue newer_success = row["last_newer_success_at"] target_healthy = ( newer_success is not None @@ -225,6 +165,158 @@ def _triage_retries( return given_up, capped +def _intervals_retry_superseded(user_name: str, state: SyncState, row: dict) -> bool: + """Whether a queued Intervals.icu retry is for a weigh-in that a newer + one has since replaced on its date. A queued entry carries the start of + its weigh-in, so its local date is the date it was meant for.""" + from eufy_sync.intervals_plan import Reading, WeighIn, same_weigh_in + + taken = datetime.fromisoformat(row["measurement_timestamp"]) + day = taken.astimezone().date() + record = state.get_intervals_days(user_name, [day]).get(day) + if record is None or not record["readings"]: + return False + holder = WeighIn([Reading.from_json(r) for r in record["readings"]]) + if same_weigh_in(taken, row["weight_kg"], row["measurement_id"], holder): + return False + return holder.started > taken + + +def _record_intervals_day( + user_name: str, state: SyncState, day, weigh_in, payload: dict, queued: list[dict], *, uploaded: bool, +) -> None: + """Bookkeeping once a date holds weigh_in's values: the per-date record, + sync_log rows (history, the fetch cursor, and the weight-only reach-back), + and the date's retry entries.""" + now = datetime.now(timezone.utc).isoformat() + state.record_intervals_day(user_name, day, payload, [r.to_json() for r in weigh_in.readings], now) + response = json.dumps({"date": day.isoformat(), **payload}) + for r in weigh_in.readings: + if r.fetched and not state.is_synced(user_name, r.measurement_id, "intervals"): + state.record_sync( + user_name=user_name, measurement_id=r.measurement_id, + measurement_timestamp=r.timestamp.isoformat(), weight_kg=r.weight_kg, + synced_at=now, target="intervals", response=response, weight_only=r.weight_only, + ) + if any(not r.weight_only for r in weigh_in.readings): + # The processed record is in; its raw reading no longer waits. + for r in weigh_in.readings: + if r.weight_only: + state.mark_upgraded(user_name, r.measurement_id, "intervals") + for row in queued: + if datetime.fromisoformat(row["measurement_timestamp"]).astimezone().date() == day: + state.clear_upload_retry(user_name, "intervals", row["measurement_id"]) + if uploaded: + # Evidence for the give-up rule, as for Garmin: the target took a + # weigh-in newer than every older queued failure. + state.note_newer_upload(user_name, "intervals", weigh_in.started, now) + + +def _sync_intervals( + user: UserConfig, state: SyncState, client, fetched: list[EufyMeasurement], + given_up: set[tuple[str, str]], capped: dict[tuple[str, str], bool], + repair: bool, dry_run: bool, capped_errors: dict[str, str], +) -> tuple[int, str | None]: + """Bring every Intervals.icu date this run touched to its desired state + (see intervals_plan). Returns (dates sent, error message or None). + + A date is sent only when its desired values differ from what was last + sent there, or on --repair-days. Order of fetching cannot matter: the + desired state already accounts for every known reading of the date. + Failures follow the other targets' rules: permanent ones stop the target + unqueued, retryable ones queue the date under its source reading's id + and stop the target, and a capped date lets later dates through.""" + from eufy_sync.intervals_plan import Reading, desired_by_date + + readings = [] + for m in fetched: + body_comp = transform(m) + if body_comp is None: + continue + readings.append(Reading( + measurement_id=m.measurement_id, timestamp=m.timestamp, weight_kg=body_comp.weight, + body_fat_pct=None if m.weight_only else body_comp.percent_fat, weight_only=m.weight_only, + )) + if not readings: + return 0, None + + # A raw/processed pair can straddle midnight, so the neighbouring dates' + # records can hold half of a weigh-in fetched now. + nearby = { + r.timestamp.astimezone().date() + timedelta(days=offset) + for r in readings for offset in (-1, 0, 1) + } + stored = state.get_intervals_days(user.name, nearby) + for record in stored.values(): + readings.extend(Reading.from_json(r) for r in record["readings"]) + desired = desired_by_date(readings) + queued = [row for row in state.get_upload_retries(user.name) if row["target"] == "intervals"] + + sent = 0 + for day in sorted(desired): + weigh_in = desired[day] + source = weigh_in.source + payload = weigh_in.payload + last = stored.get(day) + if not repair and last is not None and last["payload"] == payload: + if not dry_run: + _record_intervals_day(user.name, state, day, weigh_in, payload, queued, uploaded=False) + logger.debug("Intervals.icu already holds %s for %s", payload, day) + continue + key = ("intervals", source.measurement_id) + if not repair and key in given_up: + logger.debug("Skipping Intervals.icu upload that was given up: %s", day) + continue + if dry_run: + print(f"[DRY RUN] Would sync to intervals: {source.weight_kg:.1f} kg for {day.isoformat()}") + sent += 1 + continue + + is_capped = key in capped + attempt = (lambda fn, _description: fn()) if is_capped else _retry + try: + attempt( + lambda: client.update_wellness(day, source.weight_kg, source.body_fat_pct), # noqa: B023 + f"Intervals.icu wellness update ({day.isoformat()})", + ) + except Exception as e: + logger.error("Upload to intervals failed for %s: %s", user.name, e) + if _is_permanent(e): + return sent, str(e) + attempts = state.record_upload_failure( + user_name=user.name, target="intervals", measurement_id=source.measurement_id, + measurement_timestamp=weigh_in.started.isoformat(), weight_kg=source.weight_kg, + failed_at=datetime.now(timezone.utc).isoformat(), + ) + # One entry per date: older weigh-ins queued for it are moot now. + for row in queued: + if ( + row["measurement_id"] != source.measurement_id + and datetime.fromisoformat(row["measurement_timestamp"]).astimezone().date() == day + ): + state.clear_upload_retry(user.name, "intervals", row["measurement_id"]) + logger.info( + "Intervals.icu will retry %s on the next run (failed %d time%s)", + day.isoformat(), attempts, "" if attempts == 1 else "s", + ) + if is_capped: + if not capped[key]: + capped_errors["intervals"] = str(e) + continue + return sent, str(e) + + _record_intervals_day(user.name, state, day, weigh_in, payload, queued, uploaded=True) + capped_errors.pop("intervals", None) + sent += 1 + detail = "weight and body fat" if "bodyFat" in payload else "weight only" + logger.info( + "Synced %.2f kg (%.1f lb) → Intervals.icu for %s (%s)", + source.weight_kg, source.weight_kg * 2.20462, day.isoformat(), detail, + ) + time.sleep(0.5) + return sent, None + + def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = None, headless: bool = False, dry_run: bool = False, repair_days: int | None = None, target: str | None = None, report: SyncReport | None = None) -> tuple[dict[str, int], dict[str, str]]: """Sync one user's Eufy data to configured targets. @@ -391,9 +483,6 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No key=lambda m: m.timestamp, ) current_weight_latest = valid_fetched[-1] if valid_fetched else None - intervals_picks = _intervals_daily_picks( - [m for m in measurements if transform(m) is not None] - ) # Backfill can return older, unsynced readings alongside a newest # reading that dedup will skip, or omit the newest reading entirely. @@ -420,7 +509,10 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No target_measurement = current_weight_latest if target_name in current_weight_timestamps else m if target_measurement is None: continue - if target_name in ("garmin", "intervals") and body_comp is None: + if target_name == "intervals": + # Sent per date after this loop; see _sync_intervals. + continue + if target_name == "garmin" and body_comp is None: logger.warning("Skipping invalid measurement: %s (%.1f kg)", m.measurement_id, m.weight_kg) continue if ( @@ -443,24 +535,6 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No logger.debug("Skipping %s upload that was given up: %s", display_name(target_name), target_measurement.measurement_id) continue - # Intervals.icu keeps one wellness record per date, and a PUT - # replaces that date's values. Send only the date's newest - # weigh-in, and never one older than what the date already - # holds from us. A processed record that replaces raw - # weight-only rows on its date resends even when its id is - # already recorded (issue #48: the ids can match). - intervals_replaces: list[str] = [] - if target_name == "intervals": - if m.measurement_id not in intervals_picks: - logger.debug("Skipping Intervals.icu for %s: a newer weigh-in that day is sent instead", m.measurement_id) - continue - day_rows = state.syncs_on_date(user.name, "intervals", _local_date(m)) - if _intervals_superseded(m.measurement_id, m.timestamp, m.weight_kg, m.weight_only, day_rows): - logger.debug("Skipping Intervals.icu for %s: a newer weigh-in for that date was already sent", m.measurement_id) - continue - if not m.weight_only: - intervals_replaces = [r["measurement_id"] for r in day_rows if r["weight_only"]] - # Still consulted in repair mode: it decides whether the sync # is recorded below, since re-uploading a known id must not # insert a second row (UNIQUE on user/measurement/target). @@ -504,7 +578,7 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No ] if len(matches) == 1: upgrade_row = matches[0] - elif synced_already and not repair and m.measurement_id not in intervals_replaces: + elif synced_already and not repair: logger.debug("Already synced to %s: %s", target_name, m.measurement_id) continue @@ -579,13 +653,6 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No lambda: client.upload_body_composition(body_comp), # noqa: B023 f"Garmin upload ({m.measurement_id})", ) - elif target_name == "intervals": - result = attempt( - lambda: client.update_wellness( # noqa: B023 - _local_date(m), body_comp.weight, body_comp.percent_fat, # noqa: B023 - ), - f"Intervals.icu wellness update ({m.measurement_id})", - ) else: result = attempt( lambda: client.update_weight(target_measurement.weight_kg), # noqa: B023 @@ -609,9 +676,6 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No logger.info("Upgraded weight-only entry to full body comp for %s", m.timestamp.astimezone().date()) if target_name == "garmin" and (upgrade_row is not None or m.measurement_id in pending): state.clear_pending_upgrade(user.name, m.measurement_id) - for replaced_id in intervals_replaces: - # That date now holds this record's values. - state.mark_upgraded(user.name, replaced_id, "intervals") if target_name in current_weight_timestamps: state.clear_upload_retries_through(user.name, target_name, target_measurement.timestamp) else: @@ -666,12 +730,7 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No counts[target_name] += 1 lb = target_measurement.weight_kg * 2.20462 - if target_name == "garmin" and not m.weight_only: - detail = "full body comp" - elif target_name == "intervals" and isinstance(result, dict) and "bodyFat" in result: - detail = "weight and body fat" - else: - detail = "weight only" + detail = "full body comp" if target_name == "garmin" and not m.weight_only else "weight only" logger.info("Synced %.2f kg (%.1f lb) → %s (%s)", target_measurement.weight_kg, lb, display_name(target_name), detail) # Small delay between uploads to avoid rate limiting @@ -682,6 +741,15 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No # nowhere to go. break + intervals_client = next((c for name, c in targets if name == "intervals"), None) + if intervals_client is not None: + sent, message = _sync_intervals( + user, state, intervals_client, fetched, given_up, capped, repair, dry_run, capped_errors, + ) + counts["intervals"] = sent + if message is not None: + errors["intervals"] = message + # No newer measurement landed after a capped failure: either nothing # newer exists or the target is down. Keep the entry; report it. for target_name, message in capped_errors.items(): diff --git a/tests/test_intervals_plan.py b/tests/test_intervals_plan.py new file mode 100644 index 0000000..2eb4cfc --- /dev/null +++ b/tests/test_intervals_plan.py @@ -0,0 +1,67 @@ +"""The per-date desired state, without a database or client.""" +from __future__ import annotations + +import itertools +from datetime import datetime, time, timedelta + +from eufy_sync.intervals_plan import Reading, desired_by_date, group_weigh_ins + +NOON = datetime.now().astimezone().replace(hour=12, minute=0, second=0, microsecond=0) - timedelta(days=2) + + +def _r(mid, seconds, kg, *, raw=False, bf=20.0, fetched=True): + return Reading(mid, NOON + timedelta(seconds=seconds), kg, None if raw else bf, raw, fetched) + + +def test_grouping_does_not_depend_on_input_order(): + readings = [_r("p", 0, 80.0), _r("r", 30, 80.05, raw=True), _r("other", 3600, 79.0)] + expected = None + for order in itertools.permutations(readings): + groups = sorted(sorted(r.measurement_id for r in w.readings) for w in group_weigh_ins(list(order))) + expected = expected or groups + assert groups == expected == [["other"], ["p", "r"]] + + +def test_processed_values_win_inside_a_weigh_in_even_when_the_raw_is_later(): + (w,) = group_weigh_ins([_r("r", 30, 80.05, raw=True), _r("p", 0, 80.0, bf=19.5)]) + assert w.source.measurement_id == "p" + assert w.payload == {"weight": 80.0, "bodyFat": 19.5} + assert w.started == NOON + + +def test_two_processed_readings_close_together_stay_separate_weigh_ins(): + assert len(group_weigh_ins([_r("a", 0, 80.0), _r("b", 30, 80.0)])) == 2 + + +def test_raw_outside_the_bounds_is_its_own_weigh_in(): + assert len(group_weigh_ins([_r("p", 0, 80.0), _r("r", 121, 80.0, raw=True)])) == 2 + assert len(group_weigh_ins([_r("p", 0, 80.0), _r("r", 30, 80.2, raw=True)])) == 2 + + +def test_a_pair_across_midnight_belongs_to_the_earlier_date(): + midnight = datetime.combine((NOON + timedelta(days=1)).date(), time()).astimezone() + raw = Reading("x", midnight - timedelta(seconds=10), 80.0, None, True) + full = Reading("x", midnight + timedelta(seconds=10), 80.0, 19.5, False) + desired = desired_by_date([full, raw]) + assert list(desired) == [raw.timestamp.astimezone().date()] + assert desired[raw.timestamp.astimezone().date()].payload == {"weight": 80.0, "bodyFat": 19.5} + + +def test_dates_known_only_from_what_was_sent_are_left_alone(): + stored = _r("old", 0, 80.0, fetched=False) + tomorrow = Reading("new", NOON + timedelta(days=1), 79.0, 20.0, False) + desired = desired_by_date([stored, tomorrow]) + assert list(desired) == [tomorrow.timestamp.astimezone().date()] + + +def test_the_newest_weigh_in_of_a_date_wins_including_what_was_sent(): + sent_later = _r("later", 3600, 80.0, fetched=False) + fetched_earlier = _r("earlier", 0, 81.0) + (weigh_in,) = desired_by_date([fetched_earlier, sent_later]).values() + assert weigh_in.source.measurement_id == "later" + + +def test_reading_json_round_trip(): + r = _r("p", 0, 80.0, bf=19.5) + back = Reading.from_json(r.to_json()) + assert back == Reading(r.measurement_id, r.timestamp, r.weight_kg, r.body_fat_pct, r.weight_only, fetched=False) diff --git a/tests/test_intervals_sync.py b/tests/test_intervals_sync.py index 74ce1fd..0a9490d 100644 --- a/tests/test_intervals_sync.py +++ b/tests/test_intervals_sync.py @@ -3,7 +3,7 @@ through the same retry queue as Garmin.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta from pathlib import Path from unittest.mock import MagicMock, patch @@ -261,12 +261,26 @@ def test_queued_entry_is_dropped_once_a_newer_weigh_in_reaches_its_date(tmp_path state = SyncState(tmp_path / "s.db") user = _user() earlier, later = _m(80.0, 2), _m(80.5, 2, 60) + _run(user, state, [earlier], fail_weights={80.0}) + assert ("intervals", earlier.measurement_id) in _rows(state) + + counts, errors, intervals, _ = _run(user, state, [earlier, later]) + + assert errors == {} and counts == {"intervals": 1} + assert _sent(intervals) == [(_day(later), 80.5, 20.0)] + assert _rows(state) == {} + state.close() + + +def test_triage_drops_a_queued_weigh_in_its_date_has_moved_past(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + earlier, later = _m(80.0, 2), _m(80.5, 2, 60) + _run(user, state, [later]) state.record_upload_failure("default", "intervals", earlier.measurement_id, earlier.timestamp.isoformat(), 80.0, NOON.isoformat()) - state.record_sync("default", later.measurement_id, later.timestamp.isoformat(), 80.5, - NOON.isoformat(), target="intervals") - counts, errors, intervals, _ = _run(user, state, [earlier, later]) + counts, errors, intervals, _ = _run(user, state, []) assert errors == {} and counts == {"intervals": 0} intervals.update_wellness.assert_not_called() @@ -278,8 +292,7 @@ def test_cursor_reaches_back_for_a_queued_failure(tmp_path: Path): state = SyncState(tmp_path / "s.db") user = _user() old, newer = _m(80.0, 10), _m(81.0, 1) - state.record_sync("default", newer.measurement_id, newer.timestamp.isoformat(), 81.0, - NOON.isoformat(), target="intervals") + _run(user, state, [newer]) state.record_upload_failure("default", "intervals", old.measurement_id, old.timestamp.isoformat(), 80.0, NOON.isoformat()) fetches: list = [] @@ -289,6 +302,7 @@ def test_cursor_reaches_back_for_a_queued_failure(tmp_path: Path): assert fetches[0] <= old.timestamp.timestamp() assert errors == {} and counts == {"intervals": 1} assert _sent(intervals) == [(_day(old), 80.0, 20.0)] + assert _rows(state) == {} state.close() @@ -374,3 +388,122 @@ def test_authentication_failure_leaves_garmin_running(tmp_path: Path): assert counts == {"garmin": 1} assert "--setup-intervals" in errors["intervals"] state.close() + + +# --- order independence (the per-date desired state) --------------------------- + + +def test_raw_arriving_after_its_processed_record_changes_nothing(tmp_path: Path): + """Codex finding 1: a fetch that returns only the raw twin of a processed + record already sent must not overwrite it with the raw weight.""" + state = SyncState(tmp_path / "s.db") + user = _user() + processed = _m(80.0, 2, 0, body_fat=19.5) + raw = EufyMeasurement("raw-id", "cust", "dev", processed.timestamp + timedelta(seconds=30), 80.08, + weight_only=True) + _run(user, state, [processed]) + + counts, errors, intervals, _ = _run(user, state, [raw]) + assert errors == {} and counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + # Nothing is left waiting for a processed record that already landed. + assert state.get_oldest_weight_only_timestamp("default", "intervals") is None + + counts, _, intervals, _ = _run(user, state, [processed, raw]) + assert counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + state.close() + + +def test_same_id_upgrade_across_midnight_lands_on_the_raw_readings_date(tmp_path: Path): + """Codex finding 2. Policy: a weigh-in belongs to the date of its + earliest reading.""" + state = SyncState(tmp_path / "s.db") + user = _user() + midnight = datetime.combine((NOON - timedelta(days=1)).date(), time()).astimezone() + raw = EufyMeasurement("cust_x", "cust", "dev", midnight - timedelta(seconds=15), 80.0, weight_only=True) + full = EufyMeasurement("cust_x", "cust", "dev", midnight + timedelta(seconds=15), 80.0, body_fat_pct=19.5) + raw_day = _day(raw) + assert raw_day != _day(full) + _run(user, state, [raw]) + + counts, errors, intervals, _ = _run(user, state, [full]) + assert errors == {} and counts == {"intervals": 1} + assert _sent(intervals) == [(raw_day, 80.0, 19.5)] + + counts, _, intervals, _ = _run(user, state, [raw, full]) + assert counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + state.close() + + +def test_repair_after_an_upgrade_resends_the_processed_values(tmp_path: Path): + """Codex finding 3: the replaced raw reading, later than the processed + one, must not block the processed values on repair.""" + state = SyncState(tmp_path / "s.db") + user = _user() + processed = _m(80.0, 2, 0, body_fat=19.5) + raw = EufyMeasurement("raw-id", "cust", "dev", processed.timestamp + timedelta(seconds=30), 80.05, + weight_only=True) + _run(user, state, [raw]) + counts, _, intervals, _ = _run(user, state, [raw, processed]) + assert counts == {"intervals": 1} + assert _sent(intervals) == [(_day(processed), 80.0, 19.5)] + + counts, _, intervals, _ = _run(user, state, [raw, processed]) + assert counts == {"intervals": 0} + + counts, _, intervals, _ = _run(user, state, [raw, processed], repair_days=7) + assert counts == {"intervals": 1} + assert _sent(intervals) == [(_day(processed), 80.0, 19.5)] + state.close() + + +@pytest.mark.parametrize("first_run, second_run", [ + (["later"], ["earlier"]), + (["earlier"], ["later"]), + (["earlier", "later"], []), + (["later", "earlier"], []), +]) +def test_the_later_weigh_in_wins_whatever_the_fetch_order(tmp_path: Path, first_run, second_run): + state = SyncState(tmp_path / "s.db") + user = _user() + by_name = {"earlier": _m(81.0, 2, 0, body_fat=21.0), "later": _m(80.0, 2, 180, body_fat=20.0)} + sent = [] + for batch in (first_run, second_run): + if batch: + _, _, intervals, _ = _run(user, state, [by_name[n] for n in batch]) + sent.extend(_sent(intervals)) + + # The last value sent to the date is what Intervals.icu holds. + assert sent[-1] == (_day(by_name["later"]), 80.0, 20.0) + state.close() + + +def test_no_put_when_a_new_weigh_in_has_the_same_values(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + first, second = _m(80.0, 2, 0), _m(80.0, 2, 240) + _run(user, state, [first]) + + counts, errors, intervals, _ = _run(user, state, [first, second]) + + assert errors == {} and counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + # The date's record now names the newer weigh-in. + day = second.timestamp.astimezone().date() + readings = state.get_intervals_days("default", [day])[day]["readings"] + assert [r["id"] for r in readings] == [second.measurement_id] + state.close() + + +def test_new_table_is_created_on_an_existing_database(tmp_path: Path): + import sqlite3 + + path = tmp_path / "s.db" + SyncState(path).close() + with sqlite3.connect(path) as conn: + conn.execute("DROP TABLE intervals_days") + state = SyncState(path) + assert state.get_intervals_days("default", [NOON.date()]) == {} + state.close() From 290ec1da05944e6b84cfd5486a5b573e0d992cb1 Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 2 Oct 2026 11:57:57 -0400 Subject: [PATCH 4/5] Fix Intervals.icu dates when first seen and keep every reading per date --- README.md | 2 +- eufy_sync/intervals_plan.py | 125 ++++++++++++++++++++--------- eufy_sync/state.py | 52 ++++++++---- eufy_sync/sync.py | 149 +++++++++++++++++++++-------------- tests/test_intervals_plan.py | 48 ++++++++--- tests/test_intervals_sync.py | 146 +++++++++++++++++++++++++++++++++- 6 files changed, 397 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 1900a55..38caa9f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Strava requires an active subscription to create a new API application. This is Setup reads your athlete profile with the key before saving anything, so a mistyped key changes nothing. It looks up your athlete ID on its own. The key goes into the credential store; `config.yaml` holds only the athlete ID. -Intervals.icu keeps one wellness record per day. Each sync sets that day's weight, plus body fat when the scale measured it. If you weigh in twice in one day, the later weigh-in wins, whichever order Eufy returns them in. A day is sent again only when its values change. The day is your computer's local date, the same date Garmin gets. In the rare case that Eufy's raw and processed records of one weigh-in fall on either side of midnight, the weigh-in counts for the earlier day. Sleep, HRV, and the other wellness fields are left alone. Intervals.icu has no fields for muscle mass, bone mass, or the rest of Garmin's body composition, so those stay in Garmin. +Intervals.icu keeps one wellness record per day. Each sync sets that day's weight, plus body fat when the scale measured it. If you weigh in twice in one day, the later weigh-in wins, whichever order Eufy returns them in. A day is sent again only when its values change. The day is your computer's local date when eufy-sync first sees the weigh-in, the same date Garmin gets. A later timezone change does not move it. In the rare case that Eufy's raw and processed records of one weigh-in fall on either side of midnight, the weigh-in counts for the earlier day. Sleep, HRV, and the other wellness fields are left alone. Intervals.icu has no fields for muscle mass, bone mass, or the rest of Garmin's body composition, so those stay in Garmin. The first sync looks back seven days. To fill in older days, run `eufy-sync --target intervals --backfill-days 30`. If Intervals.icu stops accepting the key, a notification asks you to run `eufy-sync --setup-intervals` again. `eufy-sync --disconnect-intervals` removes this target and its key. diff --git a/eufy_sync/intervals_plan.py b/eufy_sync/intervals_plan.py index 0b57d38..71794dd 100644 --- a/eufy_sync/intervals_plan.py +++ b/eufy_sync/intervals_plan.py @@ -6,20 +6,29 @@ desired values for every date it touches and sends a date only when those differ from what was last sent there. -The desired values for a date come from every valid measurement known for -it: the ones fetched this run plus the weigh-in last sent to that date. A raw -Wi-Fi reading and its processed record (the same id, or within the issue #48 -bounds of 120 s and 0.1 kg) are one weigh-in, and the processed values win -because only they carry body fat. The newest weigh-in of a date wins. - -A weigh-in whose raw and processed records fall on either side of midnight -belongs to the date of its earliest record, which is when the person stepped -on the scale. Dates are the machine's local date, as for Garmin. +The desired values for a date come from every valid reading known for it: +the ones fetched this run plus every reading stored for that date by earlier +runs, winners or not, so splitting readings across fetches gives the same +answer as fetching them together. A raw Wi-Fi reading and its processed +record (the same id, or within the issue #48 bounds of 120 s and 0.1 kg) are +one weigh-in, and the processed values win because only they carry body fat. +The newest weigh-in of a date wins, ordered by when it started (its earliest +reading). + +Dates. A reading's date is decided once, the first time it is seen, and +stored with it; a timezone change later cannot move it. A new weigh-in takes +the machine's local date of its earliest reading, so a raw/processed pair on +either side of midnight belongs to the earlier date, when the person stepped +on the scale. When a late partner joins a weigh-in that already has a date, +the existing date is kept. If two readings with different stored dates turn +out to be one weigh-in, the earliest reading's date wins and the other date +is reported as touched so it is worked out again. Pure functions only: no I/O, no clients, so every case is cheap to test. """ from __future__ import annotations +import dataclasses from dataclasses import dataclass, field from datetime import date, datetime @@ -34,7 +43,8 @@ @dataclass(frozen=True) class Reading: """One Eufy record as far as Intervals.icu cares: weight, body fat when - the scale processed it, and whether it is a raw weight-only reading.""" + the scale processed it, whether it is a raw weight-only reading, and the + date it was assigned when first seen (None until then).""" measurement_id: str timestamp: datetime @@ -42,11 +52,17 @@ class Reading: body_fat_pct: float | None weight_only: bool fetched: bool = True + assigned: date | None = None + + @property + def key(self) -> tuple[str, str, bool]: + return (self.measurement_id, self.timestamp.isoformat(), self.weight_only) def to_json(self) -> dict: return { "id": self.measurement_id, "ts": self.timestamp.isoformat(), "kg": self.weight_kg, "bf": self.body_fat_pct, "raw": self.weight_only, + "date": self.assigned.isoformat() if self.assigned else None, } @classmethod @@ -55,21 +71,19 @@ def from_json(cls, value: dict) -> "Reading": measurement_id=value["id"], timestamp=datetime.fromisoformat(value["ts"]), weight_kg=value["kg"], body_fat_pct=value.get("bf"), weight_only=bool(value.get("raw")), fetched=False, + assigned=date.fromisoformat(value["date"]) if value.get("date") else None, ) @dataclass class WeighIn: readings: list[Reading] = field(default_factory=list) + day: date | None = None @property def started(self) -> datetime: return min(r.timestamp for r in self.readings) - @property - def local_date(self) -> date: - return self.started.astimezone().date() - @property def source(self) -> Reading: """The reading whose values are sent: the newest processed one, or @@ -86,6 +100,27 @@ def payload(self) -> dict[str, float]: def fetched(self) -> bool: return any(r.fetched for r in self.readings) + @property + def processed(self) -> bool: + return any(not r.weight_only for r in self.readings) + + +@dataclass +class DayPlan: + """Every weigh-in now assigned to a date, and the one it should hold + (None when every reading moved to another date).""" + + day: date + weigh_ins: list[WeighIn] + + @property + def winner(self) -> WeighIn | None: + return max(self.weigh_ins, key=lambda w: w.started) if self.weigh_ins else None + + @property + def readings(self) -> list[Reading]: + return [r for w in self.weigh_ins for r in w.readings] + def _pairs(a: Reading, b: Reading) -> bool: if a.measurement_id == b.measurement_id: @@ -97,19 +132,26 @@ def _pairs(a: Reading, b: Reading) -> bool: ) -def group_weigh_ins(readings: list[Reading]) -> list[WeighIn]: - """Collapse raw/processed pairs (and repeats of one id) into weigh-ins. - - The same reading can arrive twice (fetched, and stored with the last - sent date); the fetched copy is kept. Union-find keeps chains together - regardless of the order the readings came in.""" +def _dedupe(readings: list[Reading]) -> list[Reading]: + """One copy per reading. The fetched copy wins for its values, but keeps + the date a stored copy was assigned.""" unique: dict[tuple[str, str, bool], Reading] = {} for r in readings: - key = (r.measurement_id, r.timestamp.isoformat(), r.weight_only) - if key not in unique or r.fetched: - unique[key] = r - items = sorted(unique.values(), key=lambda r: (r.timestamp, r.measurement_id)) + seen = unique.get(r.key) + if seen is None: + unique[r.key] = r + continue + fresh, other = (r, seen) if r.fetched else (seen, r) + unique[r.key] = dataclasses.replace(fresh, assigned=fresh.assigned or other.assigned) + return sorted(unique.values(), key=lambda r: (r.timestamp, r.measurement_id)) + +def group_weigh_ins(readings: list[Reading]) -> list[WeighIn]: + """Collapse raw/processed pairs (and repeats of one id) into weigh-ins, + each with its date set. Union-find keeps chains together regardless of + the order the readings came in. Dates are not assigned to the readings + here; see plan_days.""" + items = _dedupe(readings) parent = list(range(len(items))) def find(i: int) -> int: @@ -134,25 +176,36 @@ def find(i: int) -> int: groups: dict[int, WeighIn] = {} for i, r in enumerate(items): groups.setdefault(find(i), WeighIn()).readings.append(r) - return sorted(groups.values(), key=lambda w: w.started) + weigh_ins = sorted(groups.values(), key=lambda w: w.started) + for w in weigh_ins: + dated = [r for r in w.readings if r.assigned is not None] + w.day = min(dated, key=lambda r: r.timestamp).assigned if dated else w.started.astimezone().date() + return weigh_ins -def desired_by_date(readings: list[Reading]) -> dict[date, WeighIn]: - """The weigh-in each date should hold, for dates with a fetched reading. +def plan_days(readings: list[Reading]) -> dict[date, DayPlan]: + """Plans for the dates this run touched: dates holding a fetched reading, + and dates a reading was moved away from. Every reading in a plan carries + its (possibly new) assigned date, ready to be stored. - Dates whose only knowledge is what was already sent are left out: this - run learned nothing new about them.""" + Dates known only from what was stored are left out: nothing new was + learned about them.""" weigh_ins = group_weigh_ins(readings) - touched = {w.local_date for w in weigh_ins if w.fetched} - desired: dict[date, WeighIn] = {} + touched: set[date] = set() + for w in weigh_ins: + moved_from = {r.assigned for r in w.readings if r.assigned is not None and r.assigned != w.day} + touched |= moved_from + if w.fetched or moved_from: + touched.add(w.day) + w.readings = [dataclasses.replace(r, assigned=w.day) for r in w.readings] + plans = {day: DayPlan(day, []) for day in touched} for w in weigh_ins: - day = w.local_date - if day in touched and (day not in desired or w.started > desired[day].started): - desired[day] = w - return desired + if w.day in plans: + plans[w.day].weigh_ins.append(w) + return plans -def same_weigh_in(queued_ts: datetime, queued_kg: float, queued_id: str, weigh_in: WeighIn) -> bool: +def belongs_to(queued_ts: datetime, queued_kg: float, queued_id: str, weigh_in: WeighIn) -> bool: """Whether a queued retry (which records only id, time and weight) is one of the readings in weigh_in.""" return any( diff --git a/eufy_sync/state.py b/eufy_sync/state.py index a6ed2e3..4271d42 100644 --- a/eufy_sync/state.py +++ b/eufy_sync/state.py @@ -61,15 +61,17 @@ def _init_db(self) -> None: last_newer_success_at TEXT, PRIMARY KEY(user_name, target, measurement_id) ); - -- What eufy-sync last sent to each Intervals.icu wellness date: - -- the fields sent and the readings that made up that weigh-in. - -- New in 1.16; CREATE IF NOT EXISTS is the whole migration. + -- Per Intervals.icu wellness date: every reading assigned to it + -- (with that date, fixed when first seen), the fields last sent, + -- and which readings made up the weigh-in that was sent. New in + -- 1.16; CREATE IF NOT EXISTS is the whole migration. CREATE TABLE IF NOT EXISTS intervals_days ( user_name TEXT NOT NULL, local_date TEXT NOT NULL, - payload_json TEXT NOT NULL, - readings_json TEXT NOT NULL, - sent_at TEXT NOT NULL, + readings_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT, + winner_json TEXT, + sent_at TEXT, PRIMARY KEY(user_name, local_date) ); """) @@ -209,35 +211,51 @@ def weight_only_syncs_on_date(self, user_name: str, target: str, local_date: dat ] def get_intervals_days(self, user_name: str, dates) -> dict[date, dict]: - """Last-sent record per local date, for the dates asked about. Each - value has payload (the fields sent) and readings (their JSON form, - see intervals_plan.Reading).""" + """Stored record per local date, for the dates asked about. Each value + has readings (JSON form, see intervals_plan.Reading), payload (the + fields last sent, or None) and winner (keys of the readings that + made up that weigh-in, or None).""" keys = sorted({d.isoformat() for d in dates}) if not keys: return {} placeholders = ",".join("?" for _ in keys) cursor = self._conn.execute( - f"SELECT local_date, payload_json, readings_json FROM intervals_days" + f"SELECT local_date, readings_json, payload_json, winner_json FROM intervals_days" f" WHERE user_name = ? AND local_date IN ({placeholders})", (user_name, *keys), ) return { - date.fromisoformat(day): {"payload": json.loads(payload), "readings": json.loads(readings)} - for day, payload, readings in cursor.fetchall() + date.fromisoformat(day): { + "readings": json.loads(readings), + "payload": json.loads(payload) if payload else None, + "winner": [tuple(k) for k in json.loads(winner)] if winner else None, + } + for day, readings, payload, winner in cursor.fetchall() } - def record_intervals_day( - self, user_name: str, local_date: date, payload: dict, readings: list[dict], sent_at: str, + def save_intervals_readings(self, user_name: str, local_date: date, readings: list[dict]) -> None: + """Replace the readings assigned to a date, keeping what was sent.""" + with self._conn: + self._conn.execute( + """INSERT INTO intervals_days (user_name, local_date, readings_json) + VALUES (?, ?, ?) + ON CONFLICT(user_name, local_date) DO UPDATE SET readings_json = excluded.readings_json""", + (user_name, local_date.isoformat(), json.dumps(readings)), + ) + + def record_intervals_sent( + self, user_name: str, local_date: date, payload: dict, winner: list[tuple], sent_at: str, ) -> None: + """Note that the date now holds payload, from the winner's readings.""" with self._conn: self._conn.execute( - """INSERT INTO intervals_days (user_name, local_date, payload_json, readings_json, sent_at) + """INSERT INTO intervals_days (user_name, local_date, payload_json, winner_json, sent_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_name, local_date) DO UPDATE SET payload_json = excluded.payload_json, - readings_json = excluded.readings_json, + winner_json = excluded.winner_json, sent_at = excluded.sent_at""", - (user_name, local_date.isoformat(), json.dumps(payload), json.dumps(readings), sent_at), + (user_name, local_date.isoformat(), json.dumps(payload), json.dumps(winner), sent_at), ) def mark_upgraded(self, user_name: str, measurement_id: str, target: str) -> None: diff --git a/eufy_sync/sync.py b/eufy_sync/sync.py index 8a29572..2d6ce36 100644 --- a/eufy_sync/sync.py +++ b/eufy_sync/sync.py @@ -165,51 +165,88 @@ def _triage_retries( return given_up, capped +def _intervals_load_window(timestamps) -> set: + """Dates whose stored records may hold these readings. A reading's stored + date is within a day of its UTC date in any timezone, and a raw or + processed partner can sit across one more midnight.""" + return { + ts.astimezone(timezone.utc).date() + timedelta(days=offset) + for ts in timestamps for offset in range(-2, 3) + } + + def _intervals_retry_superseded(user_name: str, state: SyncState, row: dict) -> bool: - """Whether a queued Intervals.icu retry is for a weigh-in that a newer - one has since replaced on its date. A queued entry carries the start of - its weigh-in, so its local date is the date it was meant for.""" - from eufy_sync.intervals_plan import Reading, WeighIn, same_weigh_in + """Whether a queued Intervals.icu retry is for a weigh-in older than the + one its date last settled on. The date is the one stored with the + queued reading, not today's reading of its timestamp.""" + from eufy_sync.intervals_plan import Reading, WeighIn, belongs_to taken = datetime.fromisoformat(row["measurement_timestamp"]) - day = taken.astimezone().date() - record = state.get_intervals_days(user_name, [day]).get(day) - if record is None or not record["readings"]: - return False - holder = WeighIn([Reading.from_json(r) for r in record["readings"]]) - if same_weigh_in(taken, row["weight_kg"], row["measurement_id"], holder): - return False - return holder.started > taken - - -def _record_intervals_day( - user_name: str, state: SyncState, day, weigh_in, payload: dict, queued: list[dict], *, uploaded: bool, + mid, kg = row["measurement_id"], row["weight_kg"] + for record in state.get_intervals_days(user_name, _intervals_load_window([taken])).values(): + readings = [Reading.from_json(r) for r in record["readings"]] + if not any(belongs_to(taken, kg, mid, WeighIn([r])) for r in readings): + continue + if not record["winner"]: + return False + winner_keys = set(record["winner"]) + holder = WeighIn([r for r in readings if r.key in winner_keys]) + return bool(holder.readings) and not belongs_to(taken, kg, mid, holder) and holder.started > taken + return False + + +def _clear_settled_retries( + user_name: str, state: SyncState, plan, winner, weigh_ins, queued: list[dict], keep_id: str | None = None, +) -> None: + """Clear queued entries that a date's winner settles: entries for the + winner's own readings, and entries for weigh-ins on that date that + started before it. A newer failure, or an entry this run cannot place, + stays queued.""" + from eufy_sync.intervals_plan import belongs_to + + for row in queued: + mid = row["measurement_id"] + if mid == keep_id: + continue + taken, kg = datetime.fromisoformat(row["measurement_timestamp"]), row["weight_kg"] + if belongs_to(taken, kg, mid, winner): + state.clear_upload_retry(user_name, "intervals", mid) + continue + owner = next((w for w in weigh_ins if belongs_to(taken, kg, mid, w)), None) + if owner is not None and owner.day == plan.day and owner.started < winner.started: + state.clear_upload_retry(user_name, "intervals", mid) + + +def _settle_intervals_day( + user_name: str, state: SyncState, plan, weigh_ins, payload: dict, queued: list[dict], *, uploaded: bool, ) -> None: - """Bookkeeping once a date holds weigh_in's values: the per-date record, - sync_log rows (history, the fetch cursor, and the weight-only reach-back), - and the date's retry entries.""" + """Bookkeeping once a date holds its winner's values: what was sent, + sync_log rows (history, the fetch cursor, the weight-only reach-back), + retired raw waits, and the date's settled retries.""" + winner = plan.winner now = datetime.now(timezone.utc).isoformat() - state.record_intervals_day(user_name, day, payload, [r.to_json() for r in weigh_in.readings], now) - response = json.dumps({"date": day.isoformat(), **payload}) - for r in weigh_in.readings: + state.record_intervals_sent(user_name, plan.day, payload, [list(r.key) for r in winner.readings], now) + response = json.dumps({"date": plan.day.isoformat(), **payload}) + for r in winner.readings: if r.fetched and not state.is_synced(user_name, r.measurement_id, "intervals"): state.record_sync( user_name=user_name, measurement_id=r.measurement_id, measurement_timestamp=r.timestamp.isoformat(), weight_kg=r.weight_kg, synced_at=now, target="intervals", response=response, weight_only=r.weight_only, ) - if any(not r.weight_only for r in weigh_in.readings): - # The processed record is in; its raw reading no longer waits. - for r in weigh_in.readings: + # A raw reading stops waiting for its processed record once that record + # is in, or once a newer weigh-in holds the date: it can never be sent. + for w in plan.weigh_ins: + if w is winner and not w.processed: + continue + for r in w.readings: if r.weight_only: state.mark_upgraded(user_name, r.measurement_id, "intervals") - for row in queued: - if datetime.fromisoformat(row["measurement_timestamp"]).astimezone().date() == day: - state.clear_upload_retry(user_name, "intervals", row["measurement_id"]) + _clear_settled_retries(user_name, state, plan, winner, weigh_ins, queued) if uploaded: # Evidence for the give-up rule, as for Garmin: the target took a # weigh-in newer than every older queued failure. - state.note_newer_upload(user_name, "intervals", weigh_in.started, now) + state.note_newer_upload(user_name, "intervals", winner.started, now) def _sync_intervals( @@ -220,13 +257,14 @@ def _sync_intervals( """Bring every Intervals.icu date this run touched to its desired state (see intervals_plan). Returns (dates sent, error message or None). - A date is sent only when its desired values differ from what was last - sent there, or on --repair-days. Order of fetching cannot matter: the - desired state already accounts for every known reading of the date. - Failures follow the other targets' rules: permanent ones stop the target - unqueued, retryable ones queue the date under its source reading's id - and stop the target, and a capped date lets later dates through.""" - from eufy_sync.intervals_plan import Reading, desired_by_date + The readings of every touched date, with their dates, are stored before + anything is sent, so a failed date keeps its dates and its losers. A + date is sent only when its winner's values differ from what was last + sent there, or on --repair-days. Failures follow the other targets' + rules: permanent ones stop the target unqueued, retryable ones queue the + date under its winner's source reading and stop the target, and a capped + date lets later dates through.""" + from eufy_sync.intervals_plan import Reading, plan_days readings = [] for m in fetched: @@ -240,27 +278,27 @@ def _sync_intervals( if not readings: return 0, None - # A raw/processed pair can straddle midnight, so the neighbouring dates' - # records can hold half of a weigh-in fetched now. - nearby = { - r.timestamp.astimezone().date() + timedelta(days=offset) - for r in readings for offset in (-1, 0, 1) - } - stored = state.get_intervals_days(user.name, nearby) + stored = state.get_intervals_days(user.name, _intervals_load_window(r.timestamp for r in readings)) for record in stored.values(): readings.extend(Reading.from_json(r) for r in record["readings"]) - desired = desired_by_date(readings) + plans = plan_days(readings) + weigh_ins = [w for plan in plans.values() for w in plan.weigh_ins] + if not dry_run: + for day, plan in plans.items(): + state.save_intervals_readings(user.name, day, [r.to_json() for r in plan.readings]) queued = [row for row in state.get_upload_retries(user.name) if row["target"] == "intervals"] sent = 0 - for day in sorted(desired): - weigh_in = desired[day] - source = weigh_in.source - payload = weigh_in.payload + for day in sorted(plans): + plan = plans[day] + winner = plan.winner + if winner is None: + continue + source, payload = winner.source, winner.payload last = stored.get(day) if not repair and last is not None and last["payload"] == payload: if not dry_run: - _record_intervals_day(user.name, state, day, weigh_in, payload, queued, uploaded=False) + _settle_intervals_day(user.name, state, plan, weigh_ins, payload, queued, uploaded=False) logger.debug("Intervals.icu already holds %s for %s", payload, day) continue key = ("intervals", source.measurement_id) @@ -285,16 +323,11 @@ def _sync_intervals( return sent, str(e) attempts = state.record_upload_failure( user_name=user.name, target="intervals", measurement_id=source.measurement_id, - measurement_timestamp=weigh_in.started.isoformat(), weight_kg=source.weight_kg, + measurement_timestamp=winner.started.isoformat(), weight_kg=source.weight_kg, failed_at=datetime.now(timezone.utc).isoformat(), ) - # One entry per date: older weigh-ins queued for it are moot now. - for row in queued: - if ( - row["measurement_id"] != source.measurement_id - and datetime.fromisoformat(row["measurement_timestamp"]).astimezone().date() == day - ): - state.clear_upload_retry(user.name, "intervals", row["measurement_id"]) + # This entry now stands for the date; the ones it settles go. + _clear_settled_retries(user.name, state, plan, winner, weigh_ins, queued, keep_id=source.measurement_id) logger.info( "Intervals.icu will retry %s on the next run (failed %d time%s)", day.isoformat(), attempts, "" if attempts == 1 else "s", @@ -305,7 +338,7 @@ def _sync_intervals( continue return sent, str(e) - _record_intervals_day(user.name, state, day, weigh_in, payload, queued, uploaded=True) + _settle_intervals_day(user.name, state, plan, weigh_ins, payload, queued, uploaded=True) capped_errors.pop("intervals", None) sent += 1 detail = "weight and body fat" if "bodyFat" in payload else "weight only" diff --git a/tests/test_intervals_plan.py b/tests/test_intervals_plan.py index 2eb4cfc..767bfd5 100644 --- a/tests/test_intervals_plan.py +++ b/tests/test_intervals_plan.py @@ -2,9 +2,9 @@ from __future__ import annotations import itertools -from datetime import datetime, time, timedelta +from datetime import date, datetime, time, timedelta -from eufy_sync.intervals_plan import Reading, desired_by_date, group_weigh_ins +from eufy_sync.intervals_plan import Reading, group_weigh_ins, plan_days NOON = datetime.now().astimezone().replace(hour=12, minute=0, second=0, microsecond=0) - timedelta(days=2) @@ -42,26 +42,56 @@ def test_a_pair_across_midnight_belongs_to_the_earlier_date(): midnight = datetime.combine((NOON + timedelta(days=1)).date(), time()).astimezone() raw = Reading("x", midnight - timedelta(seconds=10), 80.0, None, True) full = Reading("x", midnight + timedelta(seconds=10), 80.0, 19.5, False) - desired = desired_by_date([full, raw]) - assert list(desired) == [raw.timestamp.astimezone().date()] - assert desired[raw.timestamp.astimezone().date()].payload == {"weight": 80.0, "bodyFat": 19.5} + plans = plan_days([full, raw]) + day = raw.timestamp.astimezone().date() + assert list(plans) == [day] + assert plans[day].winner.payload == {"weight": 80.0, "bodyFat": 19.5} + assert {r.assigned for r in plans[day].readings} == {day} def test_dates_known_only_from_what_was_sent_are_left_alone(): stored = _r("old", 0, 80.0, fetched=False) tomorrow = Reading("new", NOON + timedelta(days=1), 79.0, 20.0, False) - desired = desired_by_date([stored, tomorrow]) - assert list(desired) == [tomorrow.timestamp.astimezone().date()] + plans = plan_days([stored, tomorrow]) + assert list(plans) == [tomorrow.timestamp.astimezone().date()] def test_the_newest_weigh_in_of_a_date_wins_including_what_was_sent(): sent_later = _r("later", 3600, 80.0, fetched=False) fetched_earlier = _r("earlier", 0, 81.0) - (weigh_in,) = desired_by_date([fetched_earlier, sent_later]).values() - assert weigh_in.source.measurement_id == "later" + (plan,) = plan_days([fetched_earlier, sent_later]).values() + assert plan.winner.source.measurement_id == "later" def test_reading_json_round_trip(): r = _r("p", 0, 80.0, bf=19.5) back = Reading.from_json(r.to_json()) assert back == Reading(r.measurement_id, r.timestamp, r.weight_kg, r.body_fat_pct, r.weight_only, fetched=False) + dated = Reading.from_json({**r.to_json(), "date": "2026-10-01"}) + assert dated.assigned == date(2026, 10, 1) + + +def test_a_stored_date_is_kept_whatever_the_local_date_says_now(): + stored = Reading("a", NOON, 80.0, 20.0, False, fetched=False, assigned=date(2000, 1, 1)) + fetched_copy = Reading("a", NOON, 80.0, 20.0, False) + plans = plan_days([fetched_copy, stored]) + assert list(plans) == [date(2000, 1, 1)] + + +def test_a_late_partner_keeps_the_existing_date(): + midnight = datetime.combine((NOON + timedelta(days=1)).date(), time()).astimezone() + processed = Reading("p", midnight + timedelta(seconds=10), 80.0, 19.5, False, fetched=False, + assigned=(midnight + timedelta(seconds=10)).date()) + raw = Reading("r", midnight - timedelta(seconds=10), 80.0, None, True) + plans = plan_days([processed, raw]) + assert list(plans) == [processed.assigned] + assert {r.assigned for r in plans[processed.assigned].readings} == {processed.assigned} + + +def test_joining_readings_with_different_dates_touches_both(): + a = Reading("x", NOON, 80.0, None, True, fetched=False, assigned=date(2000, 1, 1)) + b = Reading("x", NOON + timedelta(seconds=20), 80.0, 19.5, False, fetched=False, assigned=date(2000, 1, 2)) + plans = plan_days([a, b]) + assert set(plans) == {date(2000, 1, 1), date(2000, 1, 2)} + assert plans[date(2000, 1, 2)].winner is None + assert plans[date(2000, 1, 1)].winner.payload == {"weight": 80.0, "bodyFat": 19.5} diff --git a/tests/test_intervals_sync.py b/tests/test_intervals_sync.py index 0a9490d..5e120a3 100644 --- a/tests/test_intervals_sync.py +++ b/tests/test_intervals_sync.py @@ -277,14 +277,22 @@ def test_triage_drops_a_queued_weigh_in_its_date_has_moved_past(tmp_path: Path): user = _user() earlier, later = _m(80.0, 2), _m(80.5, 2, 60) _run(user, state, [later]) + # The earlier weigh-in was seen and stored for the same date, then failed. + day = later.timestamp.astimezone().date() + stored = state.get_intervals_days("default", [day])[day]["readings"] + seen = {**stored[0], "id": earlier.measurement_id, "ts": earlier.timestamp.isoformat(), "kg": 80.0} + state.save_intervals_readings("default", day, [*stored, seen]) state.record_upload_failure("default", "intervals", earlier.measurement_id, earlier.timestamp.isoformat(), 80.0, NOON.isoformat()) + # One this run cannot place stays queued. + state.record_upload_failure("default", "intervals", "unknown", + earlier.timestamp.isoformat(), 70.0, NOON.isoformat()) counts, errors, intervals, _ = _run(user, state, []) assert errors == {} and counts == {"intervals": 0} intervals.update_wellness.assert_not_called() - assert _rows(state) == {} + assert set(_rows(state)) == {("intervals", "unknown")} state.close() @@ -490,10 +498,11 @@ def test_no_put_when_a_new_weigh_in_has_the_same_values(tmp_path: Path): assert errors == {} and counts == {"intervals": 0} intervals.update_wellness.assert_not_called() - # The date's record now names the newer weigh-in. + # The date's record now names the newer weigh-in as what it holds. day = second.timestamp.astimezone().date() - readings = state.get_intervals_days("default", [day])[day]["readings"] - assert [r["id"] for r in readings] == [second.measurement_id] + record = state.get_intervals_days("default", [day])[day] + assert [k[0] for k in record["winner"]] == [second.measurement_id] + assert {r["id"] for r in record["readings"]} == {first.measurement_id, second.measurement_id} state.close() @@ -507,3 +516,132 @@ def test_new_table_is_created_on_an_existing_database(tmp_path: Path): state = SyncState(path) assert state.get_intervals_days("default", [NOON.date()]) == {} state.close() + + +# --- follow-up review: dates, retries by identity, losers, raw waits ---------- + + +@pytest.fixture +def local_tz(monkeypatch): + """Set the machine timezone for the test, and restore it afterwards.""" + import time as time_module + + def use(name: str) -> None: + monkeypatch.setenv("TZ", name) + time_module.tzset() + + yield use + monkeypatch.undo() + time_module.tzset() + + +def test_a_timezone_change_does_not_move_a_sent_weigh_in(tmp_path: Path, local_tz): + """UTC, then UTC+2: A (23:30 UTC) stays on the date it was sent to, and + an older B fetched later cannot replace it there.""" + from datetime import timezone as tz + + state = SyncState(tmp_path / "s.db") + user = _user() + day = (datetime.now(tz.utc) - timedelta(days=3)).date() + a = EufyMeasurement("a", "cust", "dev", datetime.combine(day, time(23, 30), tz.utc), 80.0, body_fat_pct=20.0) + b = EufyMeasurement("b", "cust", "dev", datetime.combine(day, time(10, 0), tz.utc), 81.0, body_fat_pct=21.0) + + local_tz("UTC") + _, _, intervals, _ = _run(user, state, [a], backfill_days=30) + assert _sent(intervals) == [(day.isoformat(), 80.0, 20.0)] + + local_tz("Etc/GMT-2") # UTC+2: A's timestamp now reads as the next day + counts, errors, intervals, _ = _run(user, state, [b], backfill_days=30) + assert errors == {} and counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + + counts, _, intervals, _ = _run(user, state, [a, b], backfill_days=30) + assert counts == {"intervals": 0} + stored = state.get_intervals_days("default", [day, day + timedelta(days=1)]) + assert set(stored) == {day} + assert {r["id"] for r in stored[day]["readings"]} == {"a", "b"} + state.close() + + +def test_a_newer_failure_survives_a_fetch_of_only_the_older_weigh_in(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + a, b = _m(80.0, 2, -240), _m(81.0, 2, 360) + _run(user, state, [a]) + _run(user, state, [a, b], fail_weights={81.0}) + assert ("intervals", b.measurement_id) in _rows(state) + + # Only A comes back. B is still known for the date, so it is still the + # date's weigh-in; its failed retry is not cleared by A being in place. + counts, errors, intervals, _ = _run(user, state, [a], fail_weights={81.0}) + assert [w for _, w, _ in _sent(intervals)] == [81.0] * 3 + assert ("intervals", b.measurement_id) in _rows(state) + + counts, errors, intervals, _ = _run(user, state, [a]) + assert errors == {} and counts == {"intervals": 1} + assert _sent(intervals) == [(_day(b), 81.0, 20.0)] + assert _rows(state) == {} + state.close() + + +def _crp(): + p = EufyMeasurement("p", "cust", "dev", NOON - timedelta(days=2), 80.0, body_fat_pct=19.0) + c = EufyMeasurement("c", "cust", "dev", p.timestamp + timedelta(minutes=1), 81.0, body_fat_pct=22.0) + r = EufyMeasurement("r", "cust", "dev", p.timestamp + timedelta(minutes=2), 80.0, weight_only=True) + return c, r, p + + +def test_an_earlier_partner_found_later_gives_the_combined_fetchs_winner(tmp_path: Path): + """C (processed, 10:01, 81 kg) and R (raw, 10:02, 80 kg) arrive first; R + is newest. P (processed, 10:00, 80 kg) arrives alone later and is R's + partner, so that weigh-in started at 10:00 and C is now the newest.""" + c, r, p = _crp() + split = SyncState(tmp_path / "split.db") + user = _user() + _, _, intervals, _ = _run(user, split, [c, r]) + assert _sent(intervals) == [(_day(r), 80.0, None)] + _, _, intervals, _ = _run(user, split, [p]) + assert _sent(intervals) == [(_day(c), 81.0, 22.0)] + + together = SyncState(tmp_path / "together.db") + _, _, intervals, _ = _run(user, together, [c, r, p]) + assert _sent(intervals) == [(_day(c), 81.0, 22.0)] + + day = c.timestamp.astimezone().date() + for state in (split, together): + assert state.get_intervals_days("default", [day])[day]["payload"] == {"weight": 81.0, "bodyFat": 22.0} + state.close() + + +def test_a_newer_weigh_in_retires_the_dates_older_raw_wait(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + morning_raw = _m(80.0, 2, -240, weight_only=True) + _run(user, state, [morning_raw]) + assert state.get_oldest_weight_only_timestamp("default", "intervals") is not None + + evening = _m(79.5, 2, 360, body_fat=19.0) + counts, _, intervals, _ = _run(user, state, [morning_raw, evening]) + assert counts == {"intervals": 1} + assert state.get_oldest_weight_only_timestamp("default", "intervals") is None + + # The morning's processed record turning up later changes nothing. + morning_full = EufyMeasurement("morning-full", "cust", "dev", morning_raw.timestamp - timedelta(seconds=20), + 80.0, body_fat_pct=20.0) + counts, _, intervals, _ = _run(user, state, [morning_full]) + assert counts == {"intervals": 0} + intervals.update_wellness.assert_not_called() + state.close() + + +def test_a_failing_winner_replaces_older_entries_but_not_its_own(tmp_path: Path): + state = SyncState(tmp_path / "s.db") + user = _user() + earlier, later = _m(80.0, 2), _m(80.5, 2, 60) + _run(user, state, [earlier], fail_weights={80.0}) + assert set(_rows(state)) == {("intervals", earlier.measurement_id)} + + _run(user, state, [earlier, later], fail_weights={80.0, 80.5}) + + assert set(_rows(state)) == {("intervals", later.measurement_id)} + state.close() From c842e0368a06696a3c52f8798fefc6e000449f78 Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 2 Oct 2026 12:02:07 -0400 Subject: [PATCH 5/5] Skip the timezone-switch test where time.tzset is unavailable --- tests/test_intervals_sync.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_intervals_sync.py b/tests/test_intervals_sync.py index 5e120a3..807b7fd 100644 --- a/tests/test_intervals_sync.py +++ b/tests/test_intervals_sync.py @@ -535,6 +535,7 @@ def use(name: str) -> None: time_module.tzset() +@pytest.mark.skipif(not hasattr(__import__("time"), "tzset"), reason="time.tzset is POSIX-only") def test_a_timezone_change_does_not_move_a_sent_weigh_in(tmp_path: Path, local_tz): """UTC, then UTC+2: A (23:30 UTC) stays on the date it was sent to, and an older B fetched later cannot replace it there."""