Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""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, GarminConnectNotFoundError
|
|
|
|
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garmin"))
|
|
|
|
_client = None
|
|
_auth_state = "unauthenticated"
|
|
_mfa_input_queue = queue.Queue()
|
|
_login_result_queue = queue.Queue()
|
|
# Set the first time this subprocess attempts any login, whichever path
|
|
# gets there first (see _handle_call's lazy call and _handle_authenticate) --
|
|
# guarantees _startup_login runs at most once per subprocess lifetime, and
|
|
# never at all once an explicit authenticate has been attempted.
|
|
_startup_login_attempted = False
|
|
|
|
|
|
def _log(level, msg, **attrs):
|
|
"""Emit one JSON log line on stderr. internal/garmin/client.go reads
|
|
stderr line by line and re-emits these through the Go application
|
|
logger, so the backend's combined output stays a single JSON stream --
|
|
never print free text to stderr or stdout from this process (stdout is
|
|
reserved for the request/response protocol). The emitting function is
|
|
stamped as "method" automatically; the Go side adds the rest of the
|
|
log schema (type=wrapper, class=wrapper)."""
|
|
entry = {"level": level, "msg": msg, "method": sys._getframe(1).f_code.co_name}
|
|
entry.update(attrs)
|
|
print(json.dumps(entry), file=sys.stderr, flush=True)
|
|
|
|
|
|
def _prompt_mfa():
|
|
_log("debug", "invoked")
|
|
code = _mfa_input_queue.get(timeout=300)
|
|
_log("debug", "returning MFA code", code_length=len(code))
|
|
return code
|
|
|
|
|
|
def _startup_login():
|
|
"""Silently resume a cached tokenstore session, so a freshly
|
|
(re)spawned subprocess can already be authenticated for a data 'call'
|
|
that never goes through the explicit authenticate command -- e.g.
|
|
"Sync now" reaching an already-connected user's client right after a
|
|
backend restart cleared the in-memory cache.
|
|
|
|
Called lazily (see _handle_call), at most once per subprocess lifetime,
|
|
and only if nothing has explicitly called authenticate first. Running
|
|
it unconditionally at process start used to be actively counterproductive
|
|
whenever the very first command actually was authenticate: on success it
|
|
just duplicated a login _handle_authenticate was about to redo anyway
|
|
(it always rebuilds _client from scratch), and on failure it was a
|
|
wasted, unauthenticated hit against Garmin's servers moments before the
|
|
real attempt -- extra load that only makes rate-limiting worse.
|
|
|
|
Bounded to the same 10s timeout as _handle_authenticate: the actual
|
|
login runs on a background daemon thread, and this function waits up
|
|
to 10s for it before returning either way, so a slow/rate-limited
|
|
Garmin login can never block the caller indefinitely. On timeout the
|
|
triggering call fails ("Not authenticated"), but the thread keeps
|
|
running and, if the login eventually succeeds, flips _auth_state to
|
|
"authenticated" itself -- only ever from "unauthenticated", never
|
|
overriding an explicit authenticate/MFA flow that ran in the
|
|
meantime -- so subsequent calls recover without user action. A late
|
|
failure changes nothing (the state is already "unauthenticated").
|
|
|
|
Deliberately uses its own private, function-local result queue rather
|
|
than the module-level _login_result_queue that _handle_authenticate and
|
|
_handle_complete_mfa share -- those two are legitimately two halves of
|
|
one explicit, MFA-capable login flow and must share a queue for the MFA
|
|
handoff to work, but this is a plain background tokenstore resume with
|
|
no MFA involved. Sharing the queue here would let this thread's stale
|
|
result (arriving after the 10s timeout below) be dequeued by an
|
|
unrelated, later authenticate/complete_mfa call instead of that call's
|
|
own fresh result."""
|
|
global _client, _auth_state
|
|
|
|
email = os.environ.get("GARMIN_EMAIL")
|
|
password = os.environ.get("GARMIN_PASSWORD")
|
|
if not email or not password:
|
|
return
|
|
|
|
_client = Garmin(email, password)
|
|
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
|
|
|
|
def _do_startup_login():
|
|
global _auth_state
|
|
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
|
|
try:
|
|
_client.login(tokenstore=TOKENSTORE)
|
|
_log("debug", "_client.login() returned successfully")
|
|
result_queue.put(("success", None))
|
|
# A success arriving after the 10s timeout below would land in
|
|
# an abandoned queue -- flip the state here too, so later calls
|
|
# benefit from the resume. Only from "unauthenticated": a
|
|
# concurrent explicit authenticate/MFA flow owns the state once
|
|
# it has moved it anywhere else.
|
|
if _auth_state == "unauthenticated":
|
|
_auth_state = "authenticated"
|
|
except Exception as exc:
|
|
_log(
|
|
"error",
|
|
"_client.login() failed",
|
|
error=str(exc),
|
|
error_type=type(exc).__name__,
|
|
traceback=traceback.format_exc(),
|
|
)
|
|
result_queue.put(("error", str(exc)))
|
|
|
|
threading.Thread(target=_do_startup_login, daemon=True).start()
|
|
|
|
try:
|
|
status, err = result_queue.get(timeout=10)
|
|
_log("debug", "got result within 10s timeout", status=status)
|
|
if status == "success":
|
|
_auth_state = "authenticated"
|
|
else:
|
|
_log("error", "login failed", error=err)
|
|
_auth_state = "unauthenticated"
|
|
except queue.Empty:
|
|
# No assignment here: the state is already "unauthenticated", and
|
|
# writing it again could clobber a success the background thread
|
|
# records right around the timeout boundary (see _do_startup_login).
|
|
_log(
|
|
"warn",
|
|
"hit the 10s timeout but still running in the "
|
|
"background and will update auth state if it eventually succeeds",
|
|
)
|
|
|
|
|
|
def _handle_authenticate(_params):
|
|
global _client, _auth_state, _startup_login_attempted
|
|
# An explicit authenticate is happening (successful or not) -- the lazy
|
|
# startup-login fallback in _handle_call must never fire after this, it
|
|
# would be redundant at best and a wasted extra hit against Garmin at
|
|
# worst.
|
|
_startup_login_attempted = True
|
|
|
|
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_authenticate_login():
|
|
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
|
|
try:
|
|
_client.login(tokenstore=TOKENSTORE)
|
|
_log("debug", "_client.login() returned successfully")
|
|
_login_result_queue.put(("success", None))
|
|
except Exception as exc:
|
|
_log(
|
|
"error",
|
|
"_client.login() failed",
|
|
error=str(exc),
|
|
error_type=type(exc).__name__,
|
|
traceback=traceback.format_exc(),
|
|
)
|
|
_login_result_queue.put(("error", str(exc)))
|
|
|
|
_client = Garmin(email, password)
|
|
_client.prompt_mfa = _prompt_mfa
|
|
threading.Thread(target=_do_authenticate_login, daemon=True).start()
|
|
|
|
try:
|
|
status, err = _login_result_queue.get(timeout=10)
|
|
_log("debug", "got result within 10s timeout", status=status)
|
|
if status == "success":
|
|
_auth_state = "authenticated"
|
|
return {"status": "success", "message": "Authenticated successfully."}
|
|
else:
|
|
_log("error", "login failed", error=err)
|
|
return {"status": "failed", "message": f"Authentication failed: {err}"}
|
|
except queue.Empty:
|
|
_log(
|
|
"warn",
|
|
"hit the 10s timeout with no result yet, reporting mfa_required",
|
|
)
|
|
_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"]
|
|
_log("debug", "received a code, pushing to mfa queue", code_length=len(code))
|
|
_mfa_input_queue.put(code)
|
|
|
|
try:
|
|
status, err = _login_result_queue.get(timeout=30)
|
|
_log("debug", "got result", status=status, error=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:
|
|
_log(
|
|
"error",
|
|
"hit the 30s timeout with no result yet, reporting unauthenticated",
|
|
)
|
|
_auth_state = "unauthenticated"
|
|
return {
|
|
"status": "failed",
|
|
"message": "Timed out waiting for authentication to complete.",
|
|
}
|
|
|
|
|
|
def _handle_call(params):
|
|
global _startup_login_attempted
|
|
if _auth_state != "authenticated" and not _startup_login_attempted:
|
|
_startup_login_attempted = True
|
|
_startup_login()
|
|
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:
|
|
# The full failure detail travels IN the response -- error text,
|
|
# exception class, and traceback -- so the Go side can log one
|
|
# complete structured record instead of correlating stderr noise.
|
|
resp = {
|
|
"id": req.get("id"),
|
|
"error": str(exc),
|
|
"error_type": type(exc).__name__,
|
|
"traceback": traceback.format_exc(),
|
|
}
|
|
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
|
|
# side after being linked to an activity) is definitive, not a
|
|
# transient failure worth retrying forever -- marked specifically so
|
|
# internal/garmin/client.go can tell the two apart (see
|
|
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
|
if isinstance(exc, GarminConnectNotFoundError):
|
|
resp["not_found"] = True
|
|
return resp
|
|
|
|
|
|
def main():
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
req = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
# A malformed request has no id to answer to -- log and keep
|
|
# serving rather than crashing the subprocess.
|
|
_log("error", "malformed request line", error=str(exc))
|
|
continue
|
|
resp = dispatch(req)
|
|
try:
|
|
print(json.dumps(resp), flush=True)
|
|
except (TypeError, ValueError) as exc:
|
|
# A non-JSON-serializable handler result must still produce a
|
|
# protocol response, or the Go side would block on a reply.
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"id": req.get("id"),
|
|
"error": f"unserializable result: {exc}",
|
|
"error_type": type(exc).__name__,
|
|
}
|
|
),
|
|
flush=True,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as exc: # last resort: die loudly, but still in JSON
|
|
_log(
|
|
"error",
|
|
"wrapper crashed",
|
|
error=str(exc),
|
|
error_type=type(exc).__name__,
|
|
traceback=traceback.format_exc(),
|
|
)
|
|
raise SystemExit(1)
|