Per the log schema: 'package' is the Go package (absent on Python-emitted lines), 'file' the Go/Python source basename, 'class' the Go receiver or Python class (omitted when there is none -- free functions no longer masquerade under a package-as-class), 'method' the emitting function. applog.App() now takes no arguments and derives all of it from runtime.Caller, so labels can never drift from the code; the manual http/wrapper emitters and forwarded wrapper.py lines (file= wrapper.py, no package) carry the same fields. 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, file=wrapper.py)."""
|
|
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)
|