From 7985000440da52be1bf08cade05b41e200fb19c0 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 25 Jul 2026 20:35:13 +0200 Subject: [PATCH] feat(garmin): add direct garminconnect wrapper script Replaces mcp-garmin's server.py: a JSON-lines subprocess protocol (authenticate/complete_mfa/call) around garminconnect directly, no MCP. Co-Authored-By: Claude Sonnet 5 --- backend/internal/garmin/pyscript/.gitignore | 4 + .../internal/garmin/pyscript/pyproject.toml | 18 ++ .../garmin/pyscript/tests/__init__.py | 0 .../garmin/pyscript/tests/test_wrapper.py | 126 ++++++++++++++ backend/internal/garmin/pyscript/wrapper.py | 164 ++++++++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 backend/internal/garmin/pyscript/.gitignore create mode 100644 backend/internal/garmin/pyscript/pyproject.toml create mode 100644 backend/internal/garmin/pyscript/tests/__init__.py create mode 100644 backend/internal/garmin/pyscript/tests/test_wrapper.py create mode 100644 backend/internal/garmin/pyscript/wrapper.py diff --git a/backend/internal/garmin/pyscript/.gitignore b/backend/internal/garmin/pyscript/.gitignore new file mode 100644 index 0000000..965717a --- /dev/null +++ b/backend/internal/garmin/pyscript/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +.pytest_cache/ +*.pyc diff --git a/backend/internal/garmin/pyscript/pyproject.toml b/backend/internal/garmin/pyscript/pyproject.toml new file mode 100644 index 0000000..2da8e8d --- /dev/null +++ b/backend/internal/garmin/pyscript/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "geniusrun-garmin-wrapper" +version = "0.1.0" +description = "Subprocess wrapper around garminconnect for geniusrund's internal/garmin package" +requires-python = ">=3.11" +dependencies = [ + "garminconnect", +] + +[project.optional-dependencies] +dev = ["pytest"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/backend/internal/garmin/pyscript/tests/__init__.py b/backend/internal/garmin/pyscript/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/internal/garmin/pyscript/tests/test_wrapper.py b/backend/internal/garmin/pyscript/tests/test_wrapper.py new file mode 100644 index 0000000..5d1c4d7 --- /dev/null +++ b/backend/internal/garmin/pyscript/tests/test_wrapper.py @@ -0,0 +1,126 @@ +import os +import queue +from unittest.mock import MagicMock, patch + +import pytest + +import wrapper + + +@pytest.fixture(autouse=True) +def reset_state(): + original_state = wrapper._auth_state + original_client = wrapper._client + for q in (wrapper._mfa_input_queue, wrapper._login_result_queue): + while not q.empty(): + try: + q.get_nowait() + except queue.Empty: + break + yield + wrapper._auth_state = original_state + wrapper._client = original_client + + +def test_authenticate_success(): + env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"} + with patch.dict("os.environ", env): + with patch("wrapper.Garmin") as mock_garmin_cls: + mock_garmin_cls.return_value = MagicMock() + resp = wrapper.dispatch({"id": 1, "cmd": "authenticate"}) + assert resp == {"id": 1, "result": {"status": "success", "message": "Authenticated successfully."}} + assert wrapper._auth_state == "authenticated" + + +def test_authenticate_mfa_required(): + env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"} + with patch.dict("os.environ", env): + with patch("wrapper.Garmin") as mock_garmin_cls: + mock_garmin_cls.return_value = MagicMock() + with patch.object(wrapper._login_result_queue, "get", side_effect=queue.Empty): + resp = wrapper.dispatch({"id": 2, "cmd": "authenticate"}) + assert resp["result"]["status"] == "mfa_required" + assert wrapper._auth_state == "mfa_pending" + + +def test_authenticate_missing_credentials(): + with patch.dict("os.environ", {}, clear=False): + os.environ.pop("GARMIN_EMAIL", None) + os.environ.pop("GARMIN_PASSWORD", None) + resp = wrapper.dispatch({"id": 3, "cmd": "authenticate"}) + assert resp["result"]["status"] == "failed" + assert "GARMIN_EMAIL" in resp["result"]["message"] + + +def test_complete_mfa_success(): + wrapper._auth_state = "mfa_pending" + wrapper._login_result_queue.put(("success", None)) + resp = wrapper.dispatch({"id": 4, "cmd": "complete_mfa", "params": {"code": "123456"}}) + assert resp == { + "id": 4, + "result": {"status": "success", "message": "MFA accepted. Authenticated successfully."}, + } + assert wrapper._auth_state == "authenticated" + assert wrapper._mfa_input_queue.get_nowait() == "123456" + + +def test_complete_mfa_not_in_progress(): + wrapper._auth_state = "unauthenticated" + resp = wrapper.dispatch({"id": 5, "cmd": "complete_mfa", "params": {"code": "123456"}}) + assert resp["result"]["status"] == "failed" + assert "No MFA in progress" in resp["result"]["message"] + + +def test_call_dispatches_to_named_garminconnect_method(): + wrapper._auth_state = "authenticated" + wrapper._client = MagicMock() + wrapper._client.get_activities_by_date.return_value = [{"activityId": "111"}] + + resp = wrapper.dispatch({ + "id": 6, + "cmd": "call", + "params": { + "method": "get_activities_by_date", + "args": {"startdate": "2026-07-01", "enddate": "2026-07-25"}, + }, + }) + + assert resp == {"id": 6, "result": [{"activityId": "111"}]} + wrapper._client.get_activities_by_date.assert_called_once_with( + startdate="2026-07-01", enddate="2026-07-25" + ) + + +def test_call_unauthenticated_is_error(): + wrapper._auth_state = "unauthenticated" + resp = wrapper.dispatch({ + "id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}}, + }) + assert "error" in resp + assert "Not authenticated" in resp["error"] + + +def test_call_unknown_method_is_error(): + wrapper._auth_state = "authenticated" + wrapper._client = MagicMock(spec=["get_activities_by_date"]) + resp = wrapper.dispatch({ + "id": 8, "cmd": "call", "params": {"method": "delete_everything", "args": {}}, + }) + assert resp["id"] == 8 + assert "error" in resp + + +def test_call_propagates_garminconnect_exception_as_error(): + wrapper._auth_state = "authenticated" + wrapper._client = MagicMock() + wrapper._client.get_activity_splits.side_effect = Exception("not found") + resp = wrapper.dispatch({ + "id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}}, + }) + assert resp == {"id": 9, "error": "not found"} + + +def test_dispatch_unknown_cmd_is_error(): + resp = wrapper.dispatch({"id": 10, "cmd": "not_a_real_cmd"}) + assert resp["id"] == 10 + assert "unknown cmd" in resp["error"] diff --git a/backend/internal/garmin/pyscript/wrapper.py b/backend/internal/garmin/pyscript/wrapper.py new file mode 100644 index 0000000..0fced23 --- /dev/null +++ b/backend/internal/garmin/pyscript/wrapper.py @@ -0,0 +1,164 @@ +"""Subprocess wrapper around garminconnect, spoken to over newline-delimited +JSON on stdin/stdout by geniusrund's internal/garmin package. See +docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md.""" + +import json +import os +import queue +import sys +import threading +import traceback + +from garminconnect import Garmin + +TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garth")) + +_client = None +_auth_state = "unauthenticated" +_mfa_input_queue = queue.Queue() +_login_result_queue = queue.Queue() + + +def _debug(msg): + print(f"[garmin-wrapper debug] {msg}", file=sys.stderr, flush=True) + + +def _prompt_mfa(): + _debug("garminconnect invoked prompt_mfa() -- this is a REAL MFA challenge from Garmin") + code = _mfa_input_queue.get(timeout=300) + _debug(f"prompt_mfa() handing code of length {len(code)} back to garminconnect") + return code + + +def _startup_login(): + """Silently resume a cached tokenstore session at process start, so a + freshly (re)spawned subprocess is already authenticated for background + syncs that never call the explicit authenticate command.""" + global _client, _auth_state + email = os.environ.get("GARMIN_EMAIL") + password = os.environ.get("GARMIN_PASSWORD") + if not email or not password: + return + try: + _client = Garmin(email, password) + _client.login(tokenstore=TOKENSTORE) + _auth_state = "authenticated" + except Exception as exc: + _debug(f"startup tokenstore login failed: {exc}") + _auth_state = "unauthenticated" + + +def _handle_authenticate(_params): + global _client, _auth_state + + email = os.environ.get("GARMIN_EMAIL", "") + password = os.environ.get("GARMIN_PASSWORD", "") + if not email or not password: + return { + "status": "failed", + "message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.", + } + + def _do_login(): + _debug(f"background login thread starting _client.login(tokenstore={TOKENSTORE})") + try: + _client.login(tokenstore=TOKENSTORE) + _debug("_client.login() returned successfully") + _login_result_queue.put(("success", None)) + except Exception as exc: + _debug(f"_client.login() raised {type(exc).__name__}: {exc}") + _debug(traceback.format_exc()) + _login_result_queue.put(("error", str(exc))) + + _client = Garmin(email, password) + _client.prompt_mfa = _prompt_mfa + threading.Thread(target=_do_login, daemon=True).start() + + try: + status, err = _login_result_queue.get(timeout=10) + _debug(f"authenticate got result within 10s timeout: status={status}") + if status == "success": + _auth_state = "authenticated" + return {"status": "success", "message": "Authenticated successfully."} + return {"status": "failed", "message": f"Authentication failed: {err}"} + except queue.Empty: + _debug( + "authenticate hit the 10s timeout with no result yet -- reporting mfa_required, " + "but this does NOT necessarily mean prompt_mfa() was actually invoked; check " + "whether the 'REAL MFA challenge' debug line above appears to tell real MFA " + "apart from a merely slow login." + ) + _auth_state = "mfa_pending" + return { + "status": "mfa_required", + "message": "MFA required. Garmin has sent a verification code to your registered email or phone.", + } + + +def _handle_complete_mfa(params): + global _auth_state + + if _auth_state != "mfa_pending": + return {"status": "failed", "message": "No MFA in progress. Call authenticate first."} + + code = params["code"] + _debug(f"complete_mfa received a code of length {len(code)}, pushing to mfa queue") + _mfa_input_queue.put(code) + + try: + status, err = _login_result_queue.get(timeout=30) + _debug(f"complete_mfa got result: status={status} err={err}") + if status == "success": + _auth_state = "authenticated" + return {"status": "success", "message": "MFA accepted. Authenticated successfully."} + return {"status": "failed", "message": f"Authentication failed after MFA: {err}"} + except queue.Empty: + _auth_state = "unauthenticated" + return { + "status": "failed", + "message": "Timed out waiting for authentication to complete. Call authenticate again.", + } + + +def _handle_call(params): + if _auth_state != "authenticated": + raise RuntimeError("Not authenticated. Call authenticate first.") + method = params["method"] + args = params.get("args") or {} + fn = getattr(_client, method) + return fn(**args) + + +_HANDLERS = { + "authenticate": _handle_authenticate, + "complete_mfa": _handle_complete_mfa, + "call": _handle_call, +} + + +def dispatch(req): + handler = _HANDLERS.get(req.get("cmd")) + if handler is None: + return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"} + try: + result = handler(req.get("params") or {}) + return {"id": req["id"], "result": result} + except Exception as exc: + _debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}") + _debug(traceback.format_exc()) + return {"id": req.get("id"), "error": str(exc)} + + +def main(): + _startup_login() + for line in sys.stdin: + line = line.strip() + if not line: + continue + req = json.loads(line) + resp = dispatch(req) + print(json.dumps(resp), flush=True) + + +if __name__ == "__main__": + main()