From a4a7e9d6695e867954af041963a4b8621b79bbbf Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 10 Jul 2026 13:35:05 -0400 Subject: [PATCH] Upgrade weight-only raw Wi-Fi syncs when the full body-comp record arrives; 1.7.20 The raw Wi-Fi fallback syncs a weight-only measurement when the phone app hasn't processed a weigh-in yet. When the app later processes the same weigh-in, the processed record either duplicated the day in Garmin (ids differ) or was skipped as already synced, stranding the user on weight-only (ids match). Raw measurements are now flagged weight_only in the sync database. When a full record arrives for a local date with a pending weight-only sync, the weight-only Garmin entry is deleted (matched by weight, fail-open) and the full record uploads in its place. Fixes #48 Co-Authored-By: Claude Fable 5 --- eufy_sync/eufy_client.py | 5 + eufy_sync/garmin_client.py | 23 ++++ eufy_sync/state.py | 55 ++++++++- eufy_sync/sync.py | 58 ++++++++-- pyproject.toml | 2 +- tests/test_eufy_client.py | 17 +++ tests/test_garmin_client.py | 41 +++++++ tests/test_sync.py | 215 ++++++++++++++++++++++++++++++++++++ 8 files changed, 401 insertions(+), 15 deletions(-) diff --git a/eufy_sync/eufy_client.py b/eufy_sync/eufy_client.py index 7d31f6a..33184d1 100644 --- a/eufy_sync/eufy_client.py +++ b/eufy_sync/eufy_client.py @@ -43,6 +43,10 @@ class EufyMeasurement: visceral_fat_level: float | None = None metabolic_age: int | None = None bmi: float | None = None + # True for records recovered from the raw Wi-Fi endpoint, which carry + # weight but no body composition. sync upgrades these in Garmin when the + # processed record for the same weigh-in shows up later (issue #48). + weight_only: bool = False @dataclass @@ -363,6 +367,7 @@ def _parse_raw_wifi_record(self, record: dict) -> EufyMeasurement | None: device_id=record.get("device_id", "unknown"), timestamp=datetime.fromtimestamp(update_time, tz=timezone.utc), weight_kg=weight_kg, + weight_only=True, ) def _parse_record(self, record: dict) -> EufyMeasurement | None: diff --git a/eufy_sync/garmin_client.py b/eufy_sync/garmin_client.py index 39c4902..a8c0690 100644 --- a/eufy_sync/garmin_client.py +++ b/eufy_sync/garmin_client.py @@ -52,6 +52,29 @@ def has_weight_on_date(self, dt: datetime) -> bool: logger.warning("Garmin duplicate-check failed for %s: %s", date_str, e) return False + def delete_weight_entry(self, dt: datetime, weight_kg: float) -> bool: + """Delete the weigh-in we uploaded on dt's local date, matched by + weight. Used to replace a weight-only (raw Wi-Fi) upload once the + full body-comp record for the same weigh-in arrives (issue #48). + Fail-open: returns False when nothing matched or the API errored, and + the caller uploads anyway - the worst case is the duplicate we would + have had without this method.""" + date_str = dt.astimezone().strftime("%Y-%m-%d") + try: + data = self._garmin.get_daily_weigh_ins(date_str) + for entry in data.get("dateWeightList", []): + entry_kg = entry.get("weight", 0) / 1000.0 # Garmin stores grams + if abs(entry_kg - weight_kg) <= 0.1: + self._garmin.delete_weigh_in(entry["samplePk"], date_str) + logger.info("Deleted weight-only entry (%.1f kg on %s) ahead of full body comp", + weight_kg, date_str) + return True + logger.warning("No weight entry near %.1f kg found on %s to replace", weight_kg, date_str) + return False + except Exception as e: + logger.warning("Could not delete weight entry on %s: %s", date_str, e) + return False + def _add_body_composition(self, body_comp: GarminBodyComposition): # add_body_composition also accepts visceral_fat_mass, active_met, and # physique_rating; the Eufy scale does not provide those, so they are omitted. diff --git a/eufy_sync/state.py b/eufy_sync/state.py index 212c858..cfcb7ae 100644 --- a/eufy_sync/state.py +++ b/eufy_sync/state.py @@ -24,13 +24,19 @@ def _init_db(self) -> None: target TEXT NOT NULL DEFAULT 'garmin', synced_at TEXT NOT NULL, response TEXT, + weight_only INTEGER NOT NULL DEFAULT 0, UNIQUE(user_name, eufy_measurement_id, target) ); """) self._conn.commit() def _migrate_if_needed(self) -> None: - """Migrate v1 schema (garmin-only) to v2 (multi-target).""" + """Migrate v1 schema (garmin-only) to v2 (multi-target), then v2 to v3 + (weight_only flag).""" + self._migrate_multi_target() + self._migrate_weight_only_column() + + def _migrate_multi_target(self) -> None: cursor = self._conn.execute("PRAGMA table_info(sync_log)") columns = {row[1] for row in cursor.fetchall()} if not columns: @@ -65,6 +71,18 @@ def _migrate_if_needed(self) -> None: self._conn.execute("DROP TABLE sync_log") self._conn.execute("ALTER TABLE sync_log_v2 RENAME TO sync_log") + def _migrate_weight_only_column(self) -> None: + """v2 -> v3: add the weight_only flag. Existing rows predate the raw + Wi-Fi fallback, so 0 (full record) is correct for all of them.""" + cursor = self._conn.execute("PRAGMA table_info(sync_log)") + columns = {row[1] for row in cursor.fetchall()} + if not columns or "weight_only" in columns: + return + with self._conn: + self._conn.execute( + "ALTER TABLE sync_log ADD COLUMN weight_only INTEGER NOT NULL DEFAULT 0" + ) + def has_any_syncs(self, user_name: str, target: str) -> bool: """Check if a target has ever been synced to.""" cursor = self._conn.execute( @@ -106,12 +124,41 @@ def record_sync( synced_at: str, target: str = "garmin", response: str | None = None, + weight_only: bool = False, ) -> None: self._conn.execute( """INSERT INTO sync_log - (user_name, eufy_measurement_id, measurement_timestamp, weight_kg, target, synced_at, response) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - (user_name, measurement_id, measurement_timestamp, weight_kg, target, synced_at, response), + (user_name, eufy_measurement_id, measurement_timestamp, weight_kg, target, synced_at, response, weight_only) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (user_name, measurement_id, measurement_timestamp, weight_kg, target, synced_at, response, int(weight_only)), + ) + self._conn.commit() + + def weight_only_syncs_on_date(self, user_name: str, target: str, local_date: date) -> list[dict]: + """Weight-only (raw Wi-Fi) syncs for a local calendar date that have + not been upgraded to a full record yet. Each dict carries + measurement_id, measurement_timestamp, and weight_kg. Date comparison + happens in Python for the same reason as has_synced_on_date.""" + cursor = self._conn.execute( + """SELECT eufy_measurement_id, measurement_timestamp, weight_kg + FROM sync_log + WHERE user_name = ? AND target = ? AND weight_only = 1""", + (user_name, target), + ) + return [ + {"measurement_id": mid, "measurement_timestamp": ts, "weight_kg": kg} + for mid, ts, kg in cursor.fetchall() + if datetime.fromisoformat(ts).astimezone().date() == local_date + ] + + 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 + never re-syncs.""" + self._conn.execute( + """UPDATE sync_log SET weight_only = 0 + WHERE user_name = ? AND eufy_measurement_id = ? AND target = ?""", + (user_name, measurement_id, target), ) self._conn.commit() diff --git a/eufy_sync/sync.py b/eufy_sync/sync.py index cff9508..c683959 100644 --- a/eufy_sync/sync.py +++ b/eufy_sync/sync.py @@ -138,7 +138,31 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No continue for target_name, client in targets: - if state.is_synced(user.name, m.measurement_id, target_name): + synced_already = state.is_synced(user.name, m.measurement_id, target_name) + + # Issue #48: a full record can arrive for a weigh-in we + # already synced weight-only from the raw Wi-Fi endpoint. + # Depending on how Eufy timestamps the two, the ids may match + # (full record would be skipped, stranding the user on + # weight-only) or differ (full record would duplicate the + # day). Either way, replace the weight-only Garmin entry. + upgrade_row = None + if target_name == "garmin" and not m.weight_only: + local_date = m.timestamp.astimezone().date() + candidates = state.weight_only_syncs_on_date(user.name, "garmin", local_date) + if synced_already: + upgrade_row = next( + (r for r in candidates if r["measurement_id"] == m.measurement_id), None + ) + if upgrade_row is None: + logger.debug("Already synced to %s: %s", target_name, m.measurement_id) + continue + elif candidates: + upgrade_row = min( + candidates, + key=lambda r: abs(datetime.fromisoformat(r["measurement_timestamp"]) - m.timestamp), + ) + elif synced_already: logger.debug("Already synced to %s: %s", target_name, m.measurement_id) continue @@ -173,6 +197,15 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No continue if target_name == "garmin": + if upgrade_row is not None: + # Fail-open (returns False, never raises): if the old + # entry can't be removed, upload anyway - the worst + # case is the duplicate this fix exists to prevent, + # while the body comp still arrives. + client.delete_weight_entry( + datetime.fromisoformat(upgrade_row["measurement_timestamp"]), + upgrade_row["weight_kg"], + ) result = _retry( lambda: client.upload_body_composition(body_comp), f"Garmin upload ({m.measurement_id})", @@ -185,15 +218,20 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No ) response_str = json.dumps(result) if result else None - state.record_sync( - user_name=user.name, - measurement_id=m.measurement_id, - measurement_timestamp=m.timestamp.isoformat(), - weight_kg=m.weight_kg, - synced_at=datetime.now(timezone.utc).isoformat(), - target=target_name, - response=response_str, - ) + if not synced_already: + state.record_sync( + user_name=user.name, + measurement_id=m.measurement_id, + measurement_timestamp=m.timestamp.isoformat(), + weight_kg=m.weight_kg, + synced_at=datetime.now(timezone.utc).isoformat(), + target=target_name, + response=response_str, + weight_only=m.weight_only, + ) + if upgrade_row is not None: + state.mark_upgraded(user.name, upgrade_row["measurement_id"], "garmin") + logger.info("Upgraded weight-only entry to full body comp for %s", m.timestamp.date()) counts[target_name] += 1 lb = m.weight_kg * 2.20462 detail = "full body comp" if target_name == "garmin" else "weight only" diff --git a/pyproject.toml b/pyproject.toml index bceb3a4..59ca52e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "eufy-sync" -version = "1.7.19" +version = "1.7.20" description = "Sync Eufy smart scale body composition data to Garmin Connect and Strava" readme = "README.md" license = "MIT" diff --git a/tests/test_eufy_client.py b/tests/test_eufy_client.py index 80056c9..174ada6 100644 --- a/tests/test_eufy_client.py +++ b/tests/test_eufy_client.py @@ -254,3 +254,20 @@ def test_raw_fallback_degrades_on_malformed_record(): patch.object(c, "_get_raw_records", return_value=[bad]): measurements = c.fetch_measurements(after_timestamp=1_500_000_000) assert measurements == [] + + +# --------------------------------------------------------------------------- +# Issue #48: raw weight-only records must be distinguishable from processed +# ones so sync can upgrade them when the full body comp arrives later. +# --------------------------------------------------------------------------- + +def test_raw_wifi_measurement_is_marked_weight_only(): + c = _client() + m = c._parse_raw_wifi_record(_raw_wifi_record("a", 80.0, 2_000_000_000)) + assert m.weight_only is True + + +def test_processed_measurement_is_not_weight_only(): + c = _client() + m = c._parse_record(_record("a", 800, 100)) + assert m.weight_only is False diff --git a/tests/test_garmin_client.py b/tests/test_garmin_client.py index 8ce376c..cdea1fc 100644 --- a/tests/test_garmin_client.py +++ b/tests/test_garmin_client.py @@ -192,3 +192,44 @@ def test_upload_propagates_rate_limit_without_reauth(): with pytest.raises(GarminConnectTooManyRequestsError): client.upload_body_composition(bc) reauth.assert_not_called() # a 429 must not trigger a re-login + + +# --------------------------------------------------------------------------- +# Issue #48: deleting our weight-only entry so the full record replaces it +# --------------------------------------------------------------------------- + +def test_delete_weight_entry_matches_by_weight_and_deletes(): + fake = MagicMock() + fake.get_daily_weigh_ins.return_value = {"dateWeightList": [ + {"samplePk": 111, "weight": 92500.0}, # someone else's 92.5 kg entry + {"samplePk": 222, "weight": 85000.0}, # our 85.0 kg weight-only upload + ]} + client = _client_with_fake_garmin(fake) + + dt = datetime(2026, 7, 9, 7, 0, tzinfo=timezone.utc) + assert client.delete_weight_entry(dt, 85.0) is True + + date_str = dt.astimezone().strftime("%Y-%m-%d") + fake.get_daily_weigh_ins.assert_called_once_with(date_str) + fake.delete_weigh_in.assert_called_once_with(222, date_str) + + +def test_delete_weight_entry_no_match_deletes_nothing(): + fake = MagicMock() + fake.get_daily_weigh_ins.return_value = {"dateWeightList": [ + {"samplePk": 111, "weight": 92500.0}, + ]} + client = _client_with_fake_garmin(fake) + + assert client.delete_weight_entry(datetime(2026, 7, 9, 7, 0, tzinfo=timezone.utc), 85.0) is False + fake.delete_weigh_in.assert_not_called() + + +def test_delete_weight_entry_fails_open_on_api_error(): + """A failed lookup or delete must not break the sync run; the caller + uploads anyway and the worst case is the pre-existing duplicate.""" + fake = MagicMock() + fake.get_daily_weigh_ins.side_effect = RuntimeError("Garmin 500") + client = _client_with_fake_garmin(fake) + + assert client.delete_weight_entry(datetime(2026, 7, 9, 7, 0, tzinfo=timezone.utc), 85.0) is False diff --git a/tests/test_sync.py b/tests/test_sync.py index 413787e..d5c64c6 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -464,3 +464,218 @@ def test_other_source_same_day_entry_is_still_skipped(tmp_path: Path): state.close() + + +# --------------------------------------------------------------------------- +# Issue #48: state tracking for weight-only (raw Wi-Fi) syncs +# --------------------------------------------------------------------------- + +def test_weight_only_sync_is_tracked_and_found_by_date(tmp_path: Path): + state = SyncState(tmp_path / "test.db") + ts = datetime(2026, 7, 9, 7, 0, tzinfo=timezone.utc) + + state.record_sync( + "user1", "cust_100", ts.isoformat(), 85.0, + "2026-07-09T07:01:00+00:00", target="garmin", weight_only=True, + ) + + rows = state.weight_only_syncs_on_date("user1", "garmin", ts.astimezone().date()) + assert len(rows) == 1 + assert rows[0]["measurement_id"] == "cust_100" + assert rows[0]["weight_kg"] == 85.0 + assert datetime.fromisoformat(rows[0]["measurement_timestamp"]) == ts + + # Other dates, targets, and users see nothing + assert state.weight_only_syncs_on_date("user1", "garmin", ts.date() + timedelta(days=1)) == [] + assert state.weight_only_syncs_on_date("user1", "strava", ts.astimezone().date()) == [] + assert state.weight_only_syncs_on_date("roommate", "garmin", ts.astimezone().date()) == [] + + state.close() + + +def test_full_sync_is_not_upgradable(tmp_path: Path): + """Default record_sync (a processed record) must never be offered for upgrade.""" + state = SyncState(tmp_path / "test.db") + ts = datetime(2026, 7, 9, 7, 0, tzinfo=timezone.utc) + state.record_sync("user1", "cust_100", ts.isoformat(), 85.0, + "2026-07-09T07:01:00+00:00", target="garmin") + + assert state.weight_only_syncs_on_date("user1", "garmin", ts.astimezone().date()) == [] + state.close() + + +def test_mark_upgraded_clears_weight_only(tmp_path: Path): + state = SyncState(tmp_path / "test.db") + ts = datetime(2026, 7, 9, 7, 0, tzinfo=timezone.utc) + state.record_sync("user1", "cust_100", ts.isoformat(), 85.0, + "2026-07-09T07:01:00+00:00", target="garmin", weight_only=True) + + state.mark_upgraded("user1", "cust_100", "garmin") + + assert state.weight_only_syncs_on_date("user1", "garmin", ts.astimezone().date()) == [] + # The sync itself is still recorded (no re-upload of the raw record) + assert state.is_synced("user1", "cust_100", "garmin") + state.close() + + +def test_weight_only_column_migration(tmp_path: Path): + """A database created before the weight_only column must open cleanly, + with existing rows treated as full records (they predate the raw + fallback, so none of them can be weight-only).""" + db_path = tmp_path / "test.db" + conn = sqlite3.connect(str(db_path)) + conn.executescript(""" + CREATE TABLE sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_name TEXT NOT NULL, + eufy_measurement_id TEXT NOT NULL, + measurement_timestamp TEXT NOT NULL, + weight_kg REAL, + target TEXT NOT NULL DEFAULT 'garmin', + synced_at TEXT NOT NULL, + response TEXT, + UNIQUE(user_name, eufy_measurement_id, target) + ); + INSERT INTO sync_log (user_name, eufy_measurement_id, measurement_timestamp, + weight_kg, target, synced_at) + VALUES ('user1', 'cust_100', '2026-07-09T07:00:00+00:00', 85.0, + 'garmin', '2026-07-09T07:01:00+00:00'); + """) + conn.commit() + conn.close() + + state = SyncState(db_path) + assert state.is_synced("user1", "cust_100", "garmin") + old_date = datetime(2026, 7, 9, 7, 0, tzinfo=timezone.utc).astimezone().date() + assert state.weight_only_syncs_on_date("user1", "garmin", old_date) == [] + # New writes with the flag work after migration + state.record_sync("user1", "cust_200", "2026-07-10T07:00:00+00:00", 84.8, + "2026-07-10T07:01:00+00:00", target="garmin", weight_only=True) + new_date = datetime(2026, 7, 10, 7, 0, tzinfo=timezone.utc).astimezone().date() + assert len(state.weight_only_syncs_on_date("user1", "garmin", new_date)) == 1 + state.close() + + +def _raw_measurement(weight_kg: float, dt: datetime) -> EufyMeasurement: + return EufyMeasurement( + measurement_id=f"cust_{int(dt.timestamp())}", + customer_id="cust", + device_id="dev", + timestamp=dt, + weight_kg=weight_kg, + weight_only=True, + ) + + +def _full_measurement(weight_kg: float, dt: datetime, measurement_id: str | None = None) -> EufyMeasurement: + return EufyMeasurement( + measurement_id=measurement_id or f"cust_{int(dt.timestamp())}", + customer_id="cust", + device_id="dev", + timestamp=dt, + weight_kg=weight_kg, + body_fat_pct=18.5, + muscle_mass_kg=45.0, + ) + + +def test_full_record_with_same_id_upgrades_weight_only_sync(tmp_path: Path): + """Issue #48, blocked case: when the raw and processed record share a + measurement id, the processed record used to be skipped as already + synced, stranding the user on weight-only forever. It must instead + replace the weight-only entry in Garmin.""" + state = SyncState(tmp_path / "test.db") + user = _garmin_user() + dt = datetime(2026, 5, 10, 7, 0, tzinfo=timezone.utc) + + raw = _raw_measurement(85.0, dt) + fake_garmin_1 = _run_garmin_sync(user, state, [raw], has_weight_on_date_return=False) + fake_garmin_1.upload_body_composition.assert_called_once() + + full = _full_measurement(85.0, dt) # same id: same customer + timestamp + assert full.measurement_id == raw.measurement_id + fake_garmin_2 = _run_garmin_sync(user, state, [full], has_weight_on_date_return=True) + + fake_garmin_2.delete_weight_entry.assert_called_once() + del_dt, del_weight = fake_garmin_2.delete_weight_entry.call_args.args + assert del_dt == dt + assert del_weight == 85.0 + fake_garmin_2.upload_body_composition.assert_called_once() + + # The weigh-in is no longer upgradable; a third run does nothing. + assert state.weight_only_syncs_on_date(user.name, "garmin", dt.astimezone().date()) == [] + fake_garmin_3 = _run_garmin_sync(user, state, [full], has_weight_on_date_return=True) + fake_garmin_3.upload_body_composition.assert_not_called() + fake_garmin_3.delete_weight_entry.assert_not_called() + + state.close() + + +def test_full_record_with_different_id_replaces_instead_of_duplicating(tmp_path: Path): + """Issue #48, duplicate case: when the processed record carries a + different timestamp (hence id), it used to upload alongside the raw one, + giving Garmin a same-day duplicate. It must delete the weight-only entry + first.""" + state = SyncState(tmp_path / "test.db") + user = _garmin_user() + weigh_in = datetime(2026, 5, 10, 7, 0, tzinfo=timezone.utc) + processed = datetime(2026, 5, 10, 9, 30, tzinfo=timezone.utc) # app opened later + + raw = _raw_measurement(85.0, weigh_in) + _run_garmin_sync(user, state, [raw], has_weight_on_date_return=False) + + full = _full_measurement(85.0, processed) + assert full.measurement_id != raw.measurement_id + fake_garmin_2 = _run_garmin_sync(user, state, [full], has_weight_on_date_return=True) + + fake_garmin_2.delete_weight_entry.assert_called_once() + del_dt, del_weight = fake_garmin_2.delete_weight_entry.call_args.args + assert del_dt == weigh_in # deletes the raw upload, matched by its own timestamp + assert del_weight == 85.0 + fake_garmin_2.upload_body_composition.assert_called_once() + + # Both ids are recorded; nothing left to upgrade. + assert state.is_synced(user.name, raw.measurement_id, "garmin") + assert state.is_synced(user.name, full.measurement_id, "garmin") + assert state.weight_only_syncs_on_date(user.name, "garmin", weigh_in.astimezone().date()) == [] + + state.close() + + +def test_second_full_record_same_day_does_not_delete(tmp_path: Path): + """Regression: a corrected re-weigh (two FULL records the same day) must + keep today's behavior - upload the second one, delete nothing.""" + state = SyncState(tmp_path / "test.db") + user = _garmin_user() + + first = _full_measurement(85.0, datetime(2026, 5, 10, 8, 0, tzinfo=timezone.utc)) + second = _full_measurement(84.7, datetime(2026, 5, 10, 8, 30, tzinfo=timezone.utc)) + + _run_garmin_sync(user, state, [first], has_weight_on_date_return=False) + fake_garmin_2 = _run_garmin_sync(user, state, [second], has_weight_on_date_return=True) + + fake_garmin_2.upload_body_composition.assert_called_once() + fake_garmin_2.delete_weight_entry.assert_not_called() + + state.close() + + +def test_skipped_raw_record_is_not_marked_upgradable(tmp_path: Path): + """A raw record skipped by the other-source guard was never uploaded by + us, so a later full record must not delete the other source's entry.""" + state = SyncState(tmp_path / "test.db") + user = _garmin_user() + dt = datetime(2026, 5, 10, 7, 0, tzinfo=timezone.utc) + + # Garmin already has data from another source; the raw record is skipped. + raw = _raw_measurement(85.0, dt) + fake_garmin_1 = _run_garmin_sync(user, state, [raw], has_weight_on_date_return=True) + fake_garmin_1.upload_body_composition.assert_not_called() + + assert state.weight_only_syncs_on_date(user.name, "garmin", dt.astimezone().date()) == [] + + full = _full_measurement(85.0, datetime(2026, 5, 10, 9, 30, tzinfo=timezone.utc)) + fake_garmin_2 = _run_garmin_sync(user, state, [full], has_weight_on_date_return=True) + fake_garmin_2.delete_weight_entry.assert_not_called() + + state.close()