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 <noreply@anthropic.com>
This commit is contained in:
4
backend/internal/garmin/pyscript/.gitignore
vendored
Normal file
4
backend/internal/garmin/pyscript/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
18
backend/internal/garmin/pyscript/pyproject.toml
Normal file
18
backend/internal/garmin/pyscript/pyproject.toml
Normal file
@@ -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 = ["."]
|
||||||
0
backend/internal/garmin/pyscript/tests/__init__.py
Normal file
0
backend/internal/garmin/pyscript/tests/__init__.py
Normal file
126
backend/internal/garmin/pyscript/tests/test_wrapper.py
Normal file
126
backend/internal/garmin/pyscript/tests/test_wrapper.py
Normal file
@@ -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"]
|
||||||
164
backend/internal/garmin/pyscript/wrapper.py
Normal file
164
backend/internal/garmin/pyscript/wrapper.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user