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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Syncs body composition from a Eufy smart scale to Garmin Connect, plus current w

| Target | What syncs |
|--------|------------|
| Garmin Connect | Full body composition: weight, body fat, muscle mass, bone mass, hydration, BMR, visceral fat, metabolic age |
| Garmin Connect | Full body composition: weight, BMI, body fat, muscle mass, bone mass, hydration, BMR, visceral fat, metabolic age |
| Strava | Current weight |
| Zwift (experimental, opt-in) | Current weight, verified after saving |

Expand Down
2 changes: 1 addition & 1 deletion eufy_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Sync Eufy smart scale body composition data to Garmin Connect and Strava."""

__version__ = "1.15.2"
__version__ = "1.15.3"

# Public API for programmatic use
from eufy_sync.eufy_client import EufyClient, EufyMeasurement
Expand Down
15 changes: 15 additions & 0 deletions eufy_sync/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,20 @@ def _sync_with_network_retry(user, state, **kwargs):
sleep(NETWORK_RETRY_DELAY)


def _repair_agent() -> None:
"""Heal an automatic-sync registration that can no longer start eufy-sync.

The broken case (Windows: a relative or missing program path) fails every
scheduled run before eufy-sync starts, so nothing reports it; a run the
user starts is the only chance to notice. A problem here never stops the
sync."""
try:
if platform_support.repair_agent():
logger.info("Automatic sync could not start eufy-sync; rewrote its wrapper with the full program path")
except Exception as e:
logger.debug("Could not check the automatic sync registration: %s", e)


def _exit_could_not_start(error: Exception) -> None:
msg = f"eufy-sync could not start: {error}"
print(msg)
Expand Down Expand Up @@ -382,6 +396,7 @@ def _main() -> None:
_check_target(args, config)

updater._check_for_updates()
_repair_agent()

backfill = args.backfill_days
if first_run and backfill is None:
Expand Down
35 changes: 31 additions & 4 deletions eufy_sync/cli/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,19 @@ def _check_for_updates() -> None:
pass # never let update check break a sync


# Right after a release, PyPI's JSON API can name a version that its package
# index does not serve yet, and the pinned install fails as unsatisfiable for up
# to about a minute (seen 2026-10-02 and 2026-11-05). One retry after this wait
# covers it; a failure for any other reason costs one extra minute.
UPDATE_RETRY_DELAY = 60


def _quote_cmd(cmd: list[str]) -> str:
"""Join an argv into one command string, double-quoting any element that
contains a space so a path like C:\\Program Files\\... survives as a single
argument when the string is handed to cmd.exe."""
return " ".join(f'"{part}"' if " " in part else part for part in cmd)
argument when the string is handed to cmd.exe. A parenthesis is quoted too:
the Windows retry runs inside a ( ) block, which a bare ) would close."""
return " ".join(f'"{part}"' if any(c in part for c in " ()") else part for part in cmd)


def _self_update() -> None:
Expand Down Expand Up @@ -120,9 +128,21 @@ def _self_update() -> None:
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
)
inner = "timeout /t 2 /nobreak >nul & " + _quote_cmd(cmd) + " & echo. & pause"
# cmd binds || tighter than &, so the retry runs only when the first
# install fails, and the pause runs either way. The echoed text must
# stay free of the characters cmd treats as syntax.
install_line = _quote_cmd(cmd)
retry = (
f"echo Update failed. The new version may not be ready. Trying again in {UPDATE_RETRY_DELAY} seconds."
f" & timeout /t {UPDATE_RETRY_DELAY} /nobreak >nul & {install_line}"
)
inner = f"timeout /t 2 /nobreak >nul & {install_line} || ({retry}) & echo. & pause"
# One string, not an argv list: for a list, Windows subprocess escapes
# the quotes around a spaced installer path as \", which cmd.exe does
# not understand. As a string, cmd sees plain quotes, and the operators
# in the inner line stay inside them until the new console runs it.
subprocess.Popen(
["cmd", "/c", "start", "eufy-sync update", "cmd", "/c", inner],
f'cmd /c start "eufy-sync update" cmd /c "{inner}"',
creationflags=flags,
)
print(f"Updating eufy-sync from v{__version__} to v{latest} in a new window...")
Expand All @@ -131,6 +151,13 @@ def _self_update() -> None:

print(f"Updating eufy-sync from v{__version__} to v{latest}...")
result = subprocess.run(cmd)
if result.returncode != 0:
print(
"Update failed; the new version may not be available for download yet. "
f"Trying again in {UPDATE_RETRY_DELAY} seconds."
)
time.sleep(UPDATE_RETRY_DELAY)
result = subprocess.run(cmd)
if result.returncode == 0:
print(f"Updated to v{latest}.")
else:
Expand Down
6 changes: 6 additions & 0 deletions eufy_sync/platform_support/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,9 @@ def agent_installed() -> bool:

def purge_agent() -> None:
_impl().purge_agent()


def repair_agent() -> bool:
"""Fix an automatic-sync registration that can no longer start eufy-sync.
True when something was rewritten."""
return _impl().repair_agent()
5 changes: 5 additions & 0 deletions eufy_sync/platform_support/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ def agent_installed() -> bool:

def purge_agent() -> None:
pass


def repair_agent() -> bool:
"""Nothing to repair here; see the Windows implementation."""
return False
5 changes: 5 additions & 0 deletions eufy_sync/platform_support/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,8 @@ def uninstall_agent() -> None:

def offer_agent() -> None:
_offer_launch_agent()


def repair_agent() -> bool:
"""Nothing to repair here; see the Windows implementation."""
return False
66 changes: 62 additions & 4 deletions eufy_sync/platform_support/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
binary does not require re-registering the task.

The public entry points (notify, install_agent, uninstall_agent, offer_agent,
agent_status, agent_installed, purge_agent) are what the platform_support
package dispatches to. notify raises a native toast through PowerShell WinRT,
agent_status, agent_installed, purge_agent, repair_agent) are what the
platform_support package dispatches to. notify raises a native toast through PowerShell WinRT,
fire-and-forget.
"""
from __future__ import annotations

import base64
import ntpath
import os
import platform
import re
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -116,9 +119,34 @@ def _write_wrapper(binary_path: str):
return wrapper_path


# The program path inside a wrapper written by _wrapper_content, where every
# quote of the inner cmd line is doubled. A Windows path cannot hold a quote.
_WRAPPED_BINARY = re.compile(r'cmd /c """"(.+?)"" --headless')


def _binary() -> str | None:
"""The full path of eufy-sync on PATH, or None.

On Windows, shutil.which searches the current folder first and answers
".\\eufy-sync.EXE" when run from the folder that holds the program. Task
Scheduler starts the wrapper somewhere else, where that relative path finds
nothing, so every scheduled run would fail before eufy-sync starts and
nothing would report it. Christopher (smithbuilt on GitHub) found this in
HomeVitals, his fork (issue #77).
"""
found = shutil.which("eufy-sync")
if not found:
return None
# Always resolved: Python 3.12's isabs accepts \Tools\eufy-sync.exe, which
# has no drive and points elsewhere when the task starts on another one.
# ntpath rather than os.path, so tests on macOS and Linux treat a path like
# C:\Tools\eufy-sync.exe as Windows does. On Windows they are one module.
return ntpath.abspath(found)


def _install_scheduled_task() -> None:
"""Register the per-user scheduled task for automatic sync."""
binary = shutil.which("eufy-sync")
binary = _binary()
if not binary:
print("Warning: could not find eufy-sync on PATH. Skipping auto-sync setup.")
return
Expand Down Expand Up @@ -200,7 +228,7 @@ def agent_status() -> dict | None:
if result.returncode != 0:
return {"status": "WARN", "label": "scheduled task", "detail": "not installed", "fix": "eufy-sync --install-agent"}

binary = shutil.which("eufy-sync")
binary = _binary()
if binary and wrapper_path.read_bytes() != _wrapper_content(binary).encode("utf-8"):
return {"status": "WARN", "label": "scheduled task", "detail": "outdated registration", "fix": "eufy-sync --install-agent"}

Expand Down Expand Up @@ -258,6 +286,36 @@ def notify(title: str, message: str, command: str | None = None) -> None:
pass


def repair_agent() -> bool:
"""Rewrite the wrapper when the program path in it can no longer start.

Covers a wrapper registered with a relative path (see _binary) and one
whose program has since moved or been removed. A wrapper that names a
program which exists is left alone, even if PATH now finds a different
copy first, so running a test build from another folder never repoints
automatic sync; so is a wrapper whose program path cannot be read. Only
the wrapper file changes: the task runs the wrapper by a path that stays
the same. True when the wrapper was rewritten.
"""
wrapper_path = shared.DATA_DIR / WRAPPER_NAME
if not wrapper_path.exists():
return False
match = _WRAPPED_BINARY.search(wrapper_path.read_bytes().decode("utf-8", "replace"))
if not match:
# Not a wrapper this code wrote, so nothing shows its program is
# broken. --doctor still reports it; --install-agent replaces it.
return False
if os.path.isabs(match.group(1)) and os.path.isfile(match.group(1)):
return False
binary = _binary()
if not binary:
return False
if wrapper_path.read_bytes() == _wrapper_content(binary).encode("utf-8"):
return False
_write_wrapper(binary)
return True


def install_agent() -> None:
_install_scheduled_task()

Expand Down
8 changes: 7 additions & 1 deletion eufy_sync/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +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.network import is_transient_network_error
from eufy_sync.reporting import SyncReport
from eufy_sync.state import SyncState
from eufy_sync.transform import transform
Expand Down Expand Up @@ -211,7 +212,12 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No
client.authenticate()
targets.append((target_name, client))
except Exception as e:
logger.error("Authentication failed for %s/%s: %s", user.name, target_name, e)
if is_transient_network_error(str(e)):
# No connection is not a sign-in problem; calling it one
# sends a reader of the log after the wrong fix.
logger.warning("Could not reach %s for %s: %s", target_name, user.name, e)
else:
logger.error("Authentication failed for %s/%s: %s", user.name, target_name, e)
errors[target_name] = str(e)
if first_exception is None:
first_exception = e
Expand Down
8 changes: 7 additions & 1 deletion eufy_sync/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def transform(measurement: EufyMeasurement) -> GarminBodyComposition | None:
if not MIN_WEIGHT_KG <= measurement.weight_kg <= MAX_WEIGHT_KG:
return None

# Garmin does not work BMI out from the profile height for uploaded
# weigh-ins: an account with a height set had no BMI on any of 137 of
# them. So send the figure Eufy calculated, rounded to the FIT field's
# 0.1 resolution because the encoder truncates rather than rounds.
bmi = _clamp_or_none(measurement.bmi, 10.0, 70.0)

return GarminBodyComposition(
# Before garminconnect 0.3.17 (PR #438), add_body_composition encoded
# the FIT timestamp with mktime(timetuple()), which reads the
Expand All @@ -57,5 +63,5 @@ def transform(measurement: EufyMeasurement) -> GarminBodyComposition | None:
muscle_mass=_clamp_or_none(measurement.muscle_mass_kg, 10.0, 120.0),
basal_met=_clamp_or_none(measurement.bmr_kcal, 500, 5000),
metabolic_age=_clamp_or_none(measurement.metabolic_age, 10, 120),
bmi=None, # Let Garmin calculate from weight + height
bmi=round(bmi, 1) if bmi is not None else None,
)
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.15.2"
version = "1.15.3"
description = "Sync Eufy smart scale data to Garmin Connect, Strava, and Zwift"
readme = "README.md"
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions tests/test_garmin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_upload_maps_fields_to_add_body_composition():
muscle_mass=45.2,
basal_met=1650,
metabolic_age=28,
bmi=None,
bmi=23.1,
)
client.upload_body_composition(bc)
kwargs = fake.add_body_composition.call_args.kwargs
Expand All @@ -43,7 +43,7 @@ def test_upload_maps_fields_to_add_body_composition():
assert kwargs["percent_fat"] == 18.5
assert kwargs["visceral_fat_rating"] == 8.0
assert kwargs["basal_met"] == 1650
assert kwargs["bmi"] is None
assert kwargs["bmi"] == 23.1


def test_has_weight_on_date_true_with_daily_summaries_key():
Expand Down
21 changes: 21 additions & 0 deletions tests/test_platform_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,24 @@ def test_macos_agent_status_warns_when_not_installed(tmp_path):
assert status["label"] == "launch agent"
assert status["detail"] == "not installed"
assert status["fix"] == "eufy-sync --install-agent"


def test_repair_agent_is_a_no_op_off_windows():
assert generic.repair_agent() is False
assert macos.repair_agent() is False


def test_repair_agent_dispatches_to_the_active_platform():
with patch.object(platform_support, "_impl", return_value=generic):
assert platform_support.repair_agent() is False


def test_a_failing_repair_never_stops_the_sync(monkeypatch):
from eufy_sync.cli import app

def boom():
raise OSError("wrapper is locked")

monkeypatch.setattr(app.platform_support, "repair_agent", boom)
app._repair_agent() # must not raise

Loading
Loading