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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions eufy_sync/eufy_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions eufy_sync/garmin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 51 additions & 4 deletions eufy_sync/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()

Expand Down
58 changes: 48 additions & 10 deletions eufy_sync/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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})",
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions tests/test_eufy_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 41 additions & 0 deletions tests/test_garmin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading