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 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.7.17"
__version__ = "1.7.18"

# Public API for programmatic use
from eufy_sync.garmin_auth import GarminAuth
Expand Down
22 changes: 22 additions & 0 deletions eufy_sync/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,19 +239,41 @@ def main() -> None:
total = sum(total_counts.values())

if failures:
from eufy_sync.cli import failure_notify
reauth_needed = any("--reauth" in err for _, err in failures)
eufy_password = any("changed your Eufy password" 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)
if reauth_needed:
shared._notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth garmin")
failure_notify.clear_network_failures()
elif eufy_password:
shared._notify("eufy-sync: Eufy login failed", "Run: eufy-sync --update-password")
failure_notify.clear_network_failures()
elif multiple_profiles:
shared._notify("eufy-sync: choose your profile", "Run: eufy-sync --select-profile")
failure_notify.clear_network_failures()
elif all_transient and args.headless and not args.dry_run:
# A scheduled run that only hit network trouble. Stay quiet - the
# next run retries - unless several have failed in a row, which
# points at a real outage worth one heads-up.
count, hours = failure_notify.record_network_failure()
if failure_notify.should_escalate(count):
shared._notify(
"eufy-sync: network still down",
f"No network for ~{round(hours)}h ({count} runs). "
"Measurements are waiting and will sync when it is back.",
)
else:
fail_msg = "; ".join(f"{name}: {err[:80]}" for name, err in failures)
shared._notify("eufy-sync failed", fail_msg)
failure_notify.clear_network_failures()
logger.error("Sync failed for: %s", "; ".join(f"{n}: {e[:80]}" for n, e in failures))
elif not args.dry_run:
# A clean run means the network is back; let a future outage
# escalate from scratch.
from eufy_sync.cli import failure_notify
failure_notify.clear_network_failures()

if args.dry_run:
target_label = " and ".join(n.capitalize() for n in total_counts if total_counts[n] > 0)
Expand Down
122 changes: 122 additions & 0 deletions eufy_sync/cli/failure_notify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Decide when a failed headless sync should actually notify.

A scheduled sync fails most often because the network was briefly gone (the
Mac was asleep, Wi-Fi was flapping) when the 4-hourly timer fired. Those
failures self-heal on the next run, so firing a scary "eufy-sync failed"
notification for each one trains the user to ignore the notifications that
matter (a bad password, a revoked token).

So transient network failures on headless runs stay silent until several land
in a row, at which point one notification says the network has been down a
while and syncs are waiting. Any successful run clears the streak. Real
failures (auth, password, profile) always notify immediately and are handled
by the caller, never routed through here.
"""
from __future__ import annotations

import json
import time
from pathlib import Path

from eufy_sync.cli import shared

# Consecutive headless network failures before one escalation notification.
# The timer runs every 4h, so 3 in a row means the network has been gone for
# roughly 8-12h - long enough to be worth telling the user about.
THRESHOLD = 3

_STREAK_FILE = "network_fail_streak.json"

# Substrings (matched case-insensitively) that mark a failure as a transient
# network problem rather than something the user must fix. These are the
# DNS / socket / timeout messages that surface from httpx, requests, and the
# stdlib when connectivity is missing or coming back.
#
# Deliberately NOT here: "_ssl.c" on its own. A TLS handshake that TIMED OUT
# is a blip and is caught by "timed out" below, but "certificate verify
# failed" and "wrong version number" also carry "_ssl.c" and are persistent,
# user-actionable problems (a wrong system clock, a stale CA bundle, a
# TLS-intercepting proxy) that must notify, not be silenced.
_TRANSIENT_MARKERS = (
"nodename nor servname", # macOS getaddrinfo, no DNS
"name or service not known", # Linux getaddrinfo, no DNS
"temporary failure in name resolution",
"timed out", # connect / read / handshake timeouts
"all connection attempts failed", # httpx ConnectError, common on wake
"connection reset",
"connection refused",
"connection aborted",
"network is unreachable",
"max retries exceeded",
"temporarily unavailable",
)


def is_transient_network_error(msg: str) -> bool:
"""True when a failure message looks like a passing network problem."""
lowered = (msg or "").lower()
return any(marker in lowered for marker in _TRANSIENT_MARKERS)


def _streak_path() -> Path:
return shared.DATA_DIR / _STREAK_FILE


def _read() -> tuple[int, float]:
try:
data = json.loads(_streak_path().read_text())
except (OSError, ValueError):
return 0, 0.0
# Valid JSON that is not an object (a list, string, number, or null) has no
# .get, and a non-numeric count/since would not convert. Any of these means
# a corrupt counter, which must reset to zero, never crash the sync.
if not isinstance(data, dict):
return 0, 0.0
try:
return int(data.get("count", 0)), float(data.get("since", 0.0))
except (ValueError, TypeError):
return 0, 0.0


def _write(count: int, since: float) -> None:
try:
shared.DATA_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
_streak_path().write_text(json.dumps({"count": count, "since": since}))
except OSError:
# Losing the counter only means one extra (or one skipped) notification;
# never let it break the run.
pass


def record_network_failure(now: float | None = None) -> tuple[int, float]:
"""Add one to the consecutive-network-failure streak.

Returns (count, hours_since_first): the new streak length and how long the
network has been failing, measured from the first failure in this streak.
"""
now = time.time() if now is None else now
count, since = _read()
if count <= 0:
since = now
count += 1
_write(count, since)
hours = max(0.0, (now - since) / 3600.0)
return count, hours


def should_escalate(count: int) -> bool:
"""Notify exactly once, when the streak first reaches the threshold - not
again on every later run of a long outage (a success clears the streak and
lets a fresh outage escalate again)."""
return count == THRESHOLD


def clear_network_failures() -> None:
"""Reset the streak. Called on any successful sync or a non-network
failure, so an isolated blot never counts toward the escalation."""
path = _streak_path()
if path.exists():
try:
path.unlink()
except OSError:
pass
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.17"
version = "1.7.18"
description = "Sync Eufy smart scale body composition data to Garmin Connect and Strava"
readme = "README.md"
license = "MIT"
Expand Down
104 changes: 104 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,110 @@ def fake_sync_user(user, state, **kwargs):
assert "[DRY RUN] Would sync 2 measurements to Garmin." in out


@patch("eufy_sync.cli.status._print_summary")
@patch("eufy_sync.cli.shared._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_headless_transient_failure_silent_until_threshold(
_keyring, _migrate, _notice, _updates, mock_notify, _summary, tmp_path
):
"""A scheduled run that only hit a network blip must not fire a 'failed'
notification. Only the third consecutive network failure escalates."""
from eufy_sync.cli.app import main

config_path = _write_synced_config(tmp_path)
db_path = tmp_path / "state.db"

def fake_sync_user(user, state, **kwargs):
raise RuntimeError("[Errno 8] nodename nor servname provided, or not known")

argv = ["eufy-sync", "--config", str(config_path), "--db", str(db_path), "--headless"]

def run_once():
with patch("eufy_sync.sync.sync_user", side_effect=fake_sync_user), \
patch("sys.argv", argv), \
pytest.raises(SystemExit) as exc:
main()
return exc.value.code

assert run_once() == 1
assert run_once() == 1
mock_notify.assert_not_called() # first two blips stay silent

assert run_once() == 1
assert mock_notify.call_count == 1 # third escalates, once
title, msg = mock_notify.call_args[0][0], mock_notify.call_args[0][1]
assert "network" in title.lower()
assert "waiting" in msg.lower()


@patch("eufy_sync.cli.status._print_summary")
@patch("eufy_sync.cli.shared._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_headless_success_clears_network_streak(
_keyring, _migrate, _notice, _updates, _notify, _summary, tmp_path
):
"""A clean run resets the streak so a later isolated blip starts from zero,
not one short of escalating."""
from eufy_sync.cli.app import main
from eufy_sync.cli import shared

config_path = _write_synced_config(tmp_path)
db_path = tmp_path / "state.db"
argv = ["eufy-sync", "--config", str(config_path), "--db", str(db_path), "--headless"]
streak_file = shared.DATA_DIR / "network_fail_streak.json"

def run_with(side):
with patch("eufy_sync.sync.sync_user", side_effect=side), \
patch("sys.argv", argv), \
pytest.raises(SystemExit):
main()

boom = RuntimeError("_ssl.c:1063: The handshake operation timed out")
run_with(boom)
run_with(boom)
assert streak_file.exists() # streak building

run_with(lambda user, state, **kw: ({"garmin": 1}, {})) # clean run
assert not streak_file.exists() # cleared on success


@patch("eufy_sync.cli.status._print_summary")
@patch("eufy_sync.cli.shared._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)
@patch("eufy_sync.cli.app.sys.stdin")
def test_interactive_transient_failure_notifies_immediately(
mock_stdin, _keyring, _migrate, _notice, _updates, mock_notify, _summary, tmp_path
):
"""The silence is only for unattended runs. An interactive run surfaces a
network failure right away, as before."""
from eufy_sync.cli.app import main

mock_stdin.isatty.return_value = True
config_path = _write_synced_config(tmp_path)
db_path = tmp_path / "state.db"

def fake_sync_user(user, state, **kwargs):
raise RuntimeError("[Errno 8] nodename nor servname provided, or not known")

argv = ["eufy-sync", "--config", str(config_path), "--db", str(db_path)]
with patch("eufy_sync.sync.sync_user", side_effect=fake_sync_user), \
patch("sys.argv", argv), \
pytest.raises(SystemExit):
main()

mock_notify.assert_called_once()
assert "failed" in mock_notify.call_args[0][0].lower()


@patch("eufy_sync.cli.status._print_summary")
@patch("eufy_sync.cli.shared._notify")
@patch("eufy_sync.cli.updater._check_for_updates")
Expand Down
Loading
Loading