INITIAL COMMIT
This commit is contained in:
110
scripts/analyze_dump.py
Executable file
110
scripts/analyze_dump.py
Executable file
@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from garmin_coach_clone.coach import (
|
||||
best_workout_for_date,
|
||||
find_date_matches,
|
||||
find_workout_like_objects,
|
||||
)
|
||||
from garmin_coach_clone.dates import parse_date
|
||||
from garmin_coach_clone.io import dump_redacted_json, print_json
|
||||
from garmin_coach_clone.workouts import (
|
||||
clone_workout_payload,
|
||||
summarize_workout,
|
||||
validate_workout_payload,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Analyze saved Garmin JSON without logging in.")
|
||||
parser.add_argument("json_file", type=Path, help="Path to a saved JSON dump.")
|
||||
parser.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
|
||||
parser.add_argument(
|
||||
"--clone-dry-run",
|
||||
action="store_true",
|
||||
help="Build a local clone payload from the matched workout.",
|
||||
)
|
||||
parser.add_argument("--prefix", default="GCClone", help="Clone workout name prefix.")
|
||||
parser.add_argument(
|
||||
"--dump-json", action="store_true", help="Write analysis JSON under debug/."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
target_date = parse_date(args.date)
|
||||
data = _load_json(args.json_file)
|
||||
date_matches = find_date_matches(data, target_date)
|
||||
workout_like = find_workout_like_objects(data)
|
||||
matched = best_workout_for_date(data, target_date)
|
||||
|
||||
analysis: dict[str, Any] = {
|
||||
"source": str(args.json_file),
|
||||
"target_date": target_date.isoformat(),
|
||||
"date_match_count": len(date_matches),
|
||||
"workout_like_count": len(workout_like),
|
||||
"matched_workout": matched is not None,
|
||||
}
|
||||
|
||||
print(f"Source: {args.json_file}")
|
||||
print(f"Target date: {target_date.isoformat()}")
|
||||
print(f"Date-containing objects: {len(date_matches)}")
|
||||
print(f"Workout-like objects with workoutSegments/workoutSteps: {len(workout_like)}")
|
||||
|
||||
if matched is None:
|
||||
print("No dated workout-like object was found.")
|
||||
if args.dump_json:
|
||||
out = _analysis_path(args.json_file)
|
||||
dump_redacted_json(out, analysis)
|
||||
print(f"Analysis JSON written to {out}")
|
||||
return 1
|
||||
|
||||
print("\nMatched workout summary:")
|
||||
print(summarize_workout(matched))
|
||||
|
||||
if args.clone_dry_run:
|
||||
payload = clone_workout_payload(matched, target_date, args.prefix)
|
||||
errors = validate_workout_payload(payload)
|
||||
analysis["clone_validation_errors"] = errors
|
||||
analysis["clone_payload"] = payload
|
||||
if errors:
|
||||
print("\nLocal clone payload failed validation:")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
return_code = 1
|
||||
else:
|
||||
print("\nLocal clone payload passed validation.")
|
||||
print_json(payload)
|
||||
return_code = 0
|
||||
else:
|
||||
return_code = 0
|
||||
|
||||
if args.dump_json:
|
||||
out = _analysis_path(args.json_file)
|
||||
dump_redacted_json(out, analysis)
|
||||
print(f"Analysis JSON written to {out}")
|
||||
return return_code
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
raise SystemExit(f"File not found: {path}") from None
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SystemExit(f"Invalid JSON in {path}: {exc}") from None
|
||||
|
||||
|
||||
def _analysis_path(source: Path) -> Path:
|
||||
stem = source.stem.replace(".", "_")
|
||||
return Path("debug") / f"analysis_{stem}.json"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
144
scripts/clone_today_workout.py
Executable file
144
scripts/clone_today_workout.py
Executable file
@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from garmin_coach_clone.auth import login
|
||||
from garmin_coach_clone.coach import (
|
||||
best_workout_for_date,
|
||||
extract_training_plans,
|
||||
get_fbt_adaptive_workout,
|
||||
is_adaptive_plan,
|
||||
plan_id,
|
||||
task_workout_for_date,
|
||||
)
|
||||
from garmin_coach_clone.dates import parse_date
|
||||
from garmin_coach_clone.io import dump_redacted_json, print_json
|
||||
from garmin_coach_clone.workouts import (
|
||||
clone_workout_payload,
|
||||
existing_clone_names,
|
||||
summarize_workout,
|
||||
validate_workout_payload,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Clone a Garmin Coach workout if step data is exposed."
|
||||
)
|
||||
parser.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Convert and print JSON only.")
|
||||
parser.add_argument("--schedule", action="store_true", help="Upload and schedule the clone.")
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Create even if a clone already exists."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dump-json", action="store_true", help="Dump redacted Coach/adaptive JSON."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
target_date = parse_date(args.date)
|
||||
prefix = os.getenv("CLONE_PREFIX", "GCClone")
|
||||
api = login()
|
||||
|
||||
plans = extract_training_plans(api.get_training_plans())
|
||||
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
|
||||
if not candidates:
|
||||
print("No Garmin Coach/adaptive plan was identifiable. Cannot clone.")
|
||||
return 1
|
||||
|
||||
source = None
|
||||
source_plan_name = None
|
||||
for plan in candidates:
|
||||
pid = plan_id(plan)
|
||||
if pid is None:
|
||||
continue
|
||||
detail = (
|
||||
api.get_adaptive_training_plan_by_id(pid)
|
||||
if str(plan.get("trainingPlanCategory", "")).upper() == "FBT_ADAPTIVE"
|
||||
else api.get_training_plan_by_id(pid)
|
||||
)
|
||||
if args.dump_json:
|
||||
out = Path("debug") / f"coach_clone_source_{pid}_{target_date.isoformat()}.json"
|
||||
dump_redacted_json(out, detail)
|
||||
print(f"Redacted source JSON written to {out}")
|
||||
source = best_workout_for_date(detail, target_date)
|
||||
if source is None:
|
||||
task_workout = task_workout_for_date(detail, target_date)
|
||||
workout_uuid = task_workout.get("workoutUuid") if task_workout else None
|
||||
if workout_uuid:
|
||||
source = get_fbt_adaptive_workout(api, str(workout_uuid))
|
||||
if source is not None:
|
||||
source_plan_name = plan.get("name")
|
||||
break
|
||||
|
||||
if source is None:
|
||||
print(
|
||||
"Coach/adaptive plan data was fetched, but no object with workoutSegments "
|
||||
f"could be matched to {target_date.isoformat()}."
|
||||
)
|
||||
print(
|
||||
"This means the missing piece is Coach step extraction, "
|
||||
"not normal workout scheduling."
|
||||
)
|
||||
return 1
|
||||
|
||||
payload = clone_workout_payload(source, target_date, prefix)
|
||||
print(f"Source plan: {source_plan_name}")
|
||||
print(summarize_workout(payload))
|
||||
validation_errors = validate_workout_payload(payload)
|
||||
if validation_errors:
|
||||
print("\nConverted clone payload failed local validation. Nothing was uploaded.")
|
||||
for error in validation_errors:
|
||||
print(f" - {error}")
|
||||
return 1
|
||||
print("\nConverted clone payload passed local validation.")
|
||||
|
||||
existing = existing_clone_names(api.get_workouts(limit=100), target_date, prefix)
|
||||
if existing and not args.force:
|
||||
print("A clone already exists for this date; skipping upload:")
|
||||
for name in existing:
|
||||
print(f" - {name}")
|
||||
return 0
|
||||
|
||||
if args.dry_run or not args.schedule:
|
||||
print("\nDry run: converted clone JSON follows. Nothing was uploaded.")
|
||||
print_json(payload)
|
||||
if not args.schedule:
|
||||
print("\nPass --schedule to upload and schedule this clone.")
|
||||
return 0
|
||||
|
||||
result = api.upload_workout(payload)
|
||||
workout_id = result.get("workoutId") or result.get("id")
|
||||
if workout_id is None:
|
||||
print("Upload returned no workoutId. Raw response:")
|
||||
print_json(result)
|
||||
return 1
|
||||
|
||||
schedule_result = api.schedule_workout(workout_id, target_date.isoformat())
|
||||
print(f"Uploaded clone workout ID: {workout_id}")
|
||||
print("Schedule response:")
|
||||
print_json(schedule_result)
|
||||
print(
|
||||
f"""
|
||||
Next checks on the Edge 1030 after Garmin Connect sync:
|
||||
1. Check Training > Workouts for "{payload["workoutName"]}".
|
||||
2. Check Training > Training Plan > calendar icon > {target_date.isoformat()}.
|
||||
3. Start it with a normal route/course active and verify navigation still works.
|
||||
4. After completing, check whether Garmin Coach marks anything complete; this is not expected yet.
|
||||
"""
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
64
scripts/local_check.py
Executable file
64
scripts/local_check.py
Executable file
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMMANDS = [
|
||||
["ruff", "check", "."],
|
||||
["ty", "check"],
|
||||
["pytest"],
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/probe_garmin.py",
|
||||
"dummy",
|
||||
"--date",
|
||||
"tomorrow",
|
||||
"--dry-run",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
results = [_run(command) for command in COMMANDS]
|
||||
passed = all(result["returncode"] == 0 for result in results)
|
||||
report: dict[str, Any] = {
|
||||
"timestamp": datetime.now(tz=UTC).isoformat(),
|
||||
"passed": passed,
|
||||
"results": results,
|
||||
}
|
||||
out = ROOT / "debug" / "local_checks.json"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
|
||||
for result in results:
|
||||
status = "PASS" if result["returncode"] == 0 else "FAIL"
|
||||
print(f"{status}: {' '.join(result['command'])}")
|
||||
print(f"Local check report written to {out.relative_to(ROOT)}")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
def _run(command: list[str]) -> dict[str, Any]:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
"command": command,
|
||||
"returncode": completed.returncode,
|
||||
"stdout": completed.stdout,
|
||||
"stderr": completed.stderr,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
706
scripts/probe_garmin.py
Executable file
706
scripts/probe_garmin.py
Executable file
@ -0,0 +1,706 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from garmin_coach_clone.auth import account_summary, login
|
||||
from garmin_coach_clone.coach import (
|
||||
best_workout_for_date,
|
||||
describe_plans,
|
||||
extract_training_plans,
|
||||
find_date_matches,
|
||||
find_workout_like_objects,
|
||||
gc_api_get,
|
||||
get_fbt_adaptive_workout,
|
||||
is_adaptive_plan,
|
||||
plan_id,
|
||||
task_workout_for_date,
|
||||
)
|
||||
from garmin_coach_clone.dates import parse_date
|
||||
from garmin_coach_clone.io import dump_redacted_json, print_json
|
||||
from garmin_coach_clone.workouts import (
|
||||
build_dummy_cycling_workout,
|
||||
calendar_entry_date,
|
||||
calendar_entry_id,
|
||||
calendar_entry_name,
|
||||
find_generated_calendar_entry,
|
||||
find_generated_workout,
|
||||
generated_calendar_entries,
|
||||
generated_workouts,
|
||||
summarize_workout,
|
||||
validate_workout_payload,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Probe Garmin Connect workout feasibility.")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("login", help="Authenticate and print account/device summary.")
|
||||
sub.add_parser("plans", help="List training plans and adaptive/Coach candidates.")
|
||||
sub.add_parser("types", help="Dump Garmin Connect workout type metadata.")
|
||||
|
||||
report = sub.add_parser("report", help="Run read-only feasibility checks in one pass.")
|
||||
report.add_argument("--limit", type=int, default=25, help="Normal workout list limit.")
|
||||
report.add_argument("--dump-json", action="store_true", help="Write debug/probe_report.json.")
|
||||
|
||||
calendar = sub.add_parser("calendar", help="List calendar entries for a date.")
|
||||
calendar.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
|
||||
calendar.add_argument("--dump-json", action="store_true")
|
||||
|
||||
coach = sub.add_parser("coach", help="Inspect Coach/adaptive workout data for a date.")
|
||||
coach.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
|
||||
coach.add_argument("--dump-json", action="store_true")
|
||||
|
||||
coach_endpoints = sub.add_parser(
|
||||
"coach-endpoints",
|
||||
help="Try read-only candidate endpoints for hidden Coach workout details.",
|
||||
)
|
||||
coach_endpoints.add_argument("--date", default="today", help="today, tomorrow, or YYYY-MM-DD")
|
||||
coach_endpoints.add_argument(
|
||||
"--dump-json", action="store_true", help="Write debug/coach_endpoint_probe_*.json."
|
||||
)
|
||||
|
||||
workouts = sub.add_parser("workouts", help="List normal workouts visible via the API.")
|
||||
workouts.add_argument("--limit", type=int, default=25)
|
||||
workouts.add_argument("--dump-json", action="store_true")
|
||||
|
||||
workout = sub.add_parser("workout", help="Fetch and summarize a normal workout by ID.")
|
||||
workout.add_argument("workout_id", help="Garmin Connect workout ID")
|
||||
workout.add_argument("--dump-json", action="store_true")
|
||||
|
||||
generated = sub.add_parser("generated", help="List/delete generated probe or clone workouts.")
|
||||
generated.add_argument("--prefix", default=os.getenv("CLONE_PREFIX", "GCClone"))
|
||||
generated.add_argument("--limit", type=int, default=100)
|
||||
generated.add_argument("--delete-id", help="Delete a generated workout template by ID.")
|
||||
generated.add_argument("--confirm", action="store_true", help="Required with --delete-id.")
|
||||
|
||||
generated_calendar = sub.add_parser(
|
||||
"generated-calendar", help="List/unschedule generated calendar entries for a month."
|
||||
)
|
||||
generated_calendar.add_argument(
|
||||
"--date", default="today", help="Any date in the month to inspect."
|
||||
)
|
||||
generated_calendar.add_argument("--prefix", default=os.getenv("CLONE_PREFIX", "GCClone"))
|
||||
generated_calendar.add_argument(
|
||||
"--unschedule-id", help="Unschedule a generated calendar entry by scheduled ID."
|
||||
)
|
||||
generated_calendar.add_argument(
|
||||
"--dump-json", action="store_true", help="Write the redacted month calendar JSON."
|
||||
)
|
||||
generated_calendar.add_argument(
|
||||
"--confirm", action="store_true", help="Required with --unschedule-id."
|
||||
)
|
||||
|
||||
dummy = sub.add_parser("dummy", help="Create and optionally schedule a dummy cycling workout.")
|
||||
dummy.add_argument("--date", default="tomorrow", help="today, tomorrow, or YYYY-MM-DD")
|
||||
dummy.add_argument("--dry-run", action="store_true", help="Print JSON without uploading.")
|
||||
dummy.add_argument("--schedule", action="store_true", help="Upload and schedule the workout.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
match args.command:
|
||||
case "login":
|
||||
return command_login()
|
||||
case "plans":
|
||||
return command_plans()
|
||||
case "types":
|
||||
return command_types()
|
||||
case "report":
|
||||
return command_report(args.limit, args.dump_json)
|
||||
case "calendar":
|
||||
return command_calendar(parse_date(args.date), args.dump_json)
|
||||
case "coach":
|
||||
return command_coach(parse_date(args.date), args.dump_json)
|
||||
case "coach-endpoints":
|
||||
return command_coach_endpoints(parse_date(args.date), args.dump_json)
|
||||
case "workouts":
|
||||
return command_workouts(args.limit, args.dump_json)
|
||||
case "workout":
|
||||
return command_workout(args.workout_id, args.dump_json)
|
||||
case "generated":
|
||||
return command_generated(args.prefix, args.limit, args.delete_id, args.confirm)
|
||||
case "generated-calendar":
|
||||
return command_generated_calendar(
|
||||
parse_date(args.date),
|
||||
args.prefix,
|
||||
args.unschedule_id,
|
||||
args.dump_json,
|
||||
args.confirm,
|
||||
)
|
||||
case "dummy":
|
||||
return command_dummy(parse_date(args.date), args.dry_run, args.schedule)
|
||||
return 2
|
||||
|
||||
|
||||
def command_login() -> int:
|
||||
api = login()
|
||||
print("Authenticated.")
|
||||
print_json(account_summary(api))
|
||||
return 0
|
||||
|
||||
|
||||
def command_plans() -> int:
|
||||
api = login()
|
||||
response = api.get_training_plans()
|
||||
plans = extract_training_plans(response)
|
||||
print(describe_plans(plans))
|
||||
adaptive = [plan for plan in plans if is_adaptive_plan(plan)]
|
||||
if adaptive:
|
||||
print("\nAdaptive/Coach-looking plans:")
|
||||
print(describe_plans(adaptive))
|
||||
else:
|
||||
print("\nNo adaptive/Coach-looking plans were identifiable from the plan list.")
|
||||
return 0
|
||||
|
||||
|
||||
def command_types() -> int:
|
||||
api = login()
|
||||
data = api.connectapi("/workout-service/workout/types")
|
||||
out = Path("debug") / "workout_types.json"
|
||||
dump_redacted_json(out, data)
|
||||
print(f"Redacted Garmin workout type metadata written to {out}")
|
||||
print_json(data)
|
||||
return 0
|
||||
|
||||
|
||||
def command_report(limit: int, dump_json: bool) -> int:
|
||||
api = login()
|
||||
today = date.today()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
target_dates = [today, tomorrow]
|
||||
report: dict[str, Any] = {"dates": [day.isoformat() for day in target_dates]}
|
||||
|
||||
print("Authenticated.")
|
||||
report["account"] = account_summary(api)
|
||||
_print_section("Account")
|
||||
print_json(report["account"])
|
||||
|
||||
plans_response = _capture("training_plans", report, api.get_training_plans)
|
||||
plans = extract_training_plans(plans_response)
|
||||
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
|
||||
report["training_plan_summary"] = {
|
||||
"count": len(plans),
|
||||
"adaptive_candidate_count": len(candidates),
|
||||
"adaptive_candidates": [_plan_summary(plan) for plan in candidates],
|
||||
}
|
||||
_print_section("Training Plans")
|
||||
print(f"Plans returned: {len(plans)}")
|
||||
print(f"Adaptive/Coach-looking candidates: {len(candidates)}")
|
||||
if candidates:
|
||||
print(describe_plans(candidates))
|
||||
|
||||
types_response = _capture(
|
||||
"workout_types", report, lambda: api.connectapi("/workout-service/workout/types")
|
||||
)
|
||||
_print_section("Workout Type Metadata")
|
||||
print("Fetched." if not _is_error(types_response) else types_response["error"])
|
||||
|
||||
workouts = _capture("workouts", report, lambda: api.get_workouts(limit=limit))
|
||||
workout_count = len(workouts) if isinstance(workouts, list) else 0
|
||||
_print_section("Normal Workouts")
|
||||
print(f"Normal workouts returned: {workout_count}")
|
||||
if isinstance(workouts, list):
|
||||
for workout in workouts[:10]:
|
||||
print(
|
||||
" - id={id} sport={sport} name={name}".format(
|
||||
id=workout.get("workoutId"),
|
||||
sport=(workout.get("sportType") or {}).get("sportTypeKey"),
|
||||
name=workout.get("workoutName"),
|
||||
)
|
||||
)
|
||||
|
||||
report["calendar_summary"] = {}
|
||||
_print_section("Calendar")
|
||||
for target_date in target_dates:
|
||||
key = target_date.isoformat()
|
||||
calendar_data = _capture(
|
||||
f"calendar_{key}",
|
||||
report,
|
||||
lambda target_date=target_date: api.get_scheduled_workouts(
|
||||
target_date.year, target_date.month
|
||||
),
|
||||
)
|
||||
matches = find_date_matches(calendar_data, target_date)
|
||||
report["calendar_summary"][key] = {"date_match_count": len(matches)}
|
||||
print(f"{key}: {len(matches)} date-containing entries")
|
||||
|
||||
report["coach_summary"] = {}
|
||||
_print_section("Coach / Adaptive")
|
||||
if not candidates:
|
||||
print("No adaptive/Coach-looking plans found.")
|
||||
for plan in candidates:
|
||||
pid = plan_id(plan)
|
||||
if pid is None:
|
||||
continue
|
||||
category = str(plan.get("trainingPlanCategory", "")).upper()
|
||||
detail = _capture(
|
||||
f"coach_plan_{pid}",
|
||||
report,
|
||||
lambda pid=pid, category=category: api.get_adaptive_training_plan_by_id(pid)
|
||||
if category == "FBT_ADAPTIVE"
|
||||
else api.get_training_plan_by_id(pid),
|
||||
)
|
||||
plan_summary: dict[str, Any] = {
|
||||
"name": plan.get("name"),
|
||||
"category": plan.get("trainingPlanCategory"),
|
||||
"workout_like_count": len(find_workout_like_objects(detail)),
|
||||
"dates": {},
|
||||
}
|
||||
for target_date in target_dates:
|
||||
workout = best_workout_for_date(detail, target_date)
|
||||
plan_summary["dates"][target_date.isoformat()] = {
|
||||
"date_match_count": len(find_date_matches(detail, target_date)),
|
||||
"matched_workout": workout is not None,
|
||||
}
|
||||
report["coach_summary"][str(pid)] = plan_summary
|
||||
print(
|
||||
"Plan {pid} {name}: workout-like={count}, today={today_match}, "
|
||||
"tomorrow={tomorrow_match}".format(
|
||||
pid=pid,
|
||||
name=plan.get("name"),
|
||||
count=plan_summary["workout_like_count"],
|
||||
today_match=plan_summary["dates"][today.isoformat()]["matched_workout"],
|
||||
tomorrow_match=plan_summary["dates"][tomorrow.isoformat()]["matched_workout"],
|
||||
)
|
||||
)
|
||||
|
||||
if dump_json:
|
||||
out = Path("debug") / "probe_report.json"
|
||||
dump_redacted_json(out, report)
|
||||
print(f"\nRedacted probe report written to {out}")
|
||||
|
||||
_print_section("Next Gate")
|
||||
print(
|
||||
"If dummy upload/schedule works on the Edge and Coach matched_workout is true, "
|
||||
"run clone dry-run."
|
||||
)
|
||||
print("If Coach matched_workout is false, Coach step extraction is still the missing piece.")
|
||||
return 0
|
||||
|
||||
|
||||
def command_calendar(target_date: date, dump_json: bool) -> int:
|
||||
api = login()
|
||||
data = api.get_scheduled_workouts(target_date.year, target_date.month)
|
||||
matches = find_date_matches(data, target_date)
|
||||
print(f"Calendar entries containing {target_date.isoformat()}: {len(matches)}")
|
||||
for idx, item in enumerate(matches, start=1):
|
||||
name = item.get("workoutName") or item.get("name") or item.get("title") or "<unnamed>"
|
||||
item_id = item.get("workoutId") or item.get("scheduledWorkoutId") or item.get("id")
|
||||
print(f" {idx}. id={item_id} name={name}")
|
||||
if dump_json:
|
||||
out = Path("debug") / f"calendar_{target_date.isoformat()}.json"
|
||||
dump_redacted_json(out, data)
|
||||
print(f"Redacted calendar JSON written to {out}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_coach(target_date: date, dump_json: bool) -> int:
|
||||
api = login()
|
||||
plans_response = api.get_training_plans()
|
||||
plans = extract_training_plans(plans_response)
|
||||
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
|
||||
if not candidates:
|
||||
print("No Garmin Coach/adaptive plan was identifiable in get_training_plans().")
|
||||
if dump_json:
|
||||
out = Path("debug") / "training_plans.json"
|
||||
dump_redacted_json(out, plans_response)
|
||||
print(f"Redacted training plan list written to {out}")
|
||||
return 1
|
||||
|
||||
any_workout = False
|
||||
for plan in candidates:
|
||||
pid = plan_id(plan)
|
||||
if pid is None:
|
||||
print(f"Skipping plan without numeric ID: {plan.get('name')}")
|
||||
continue
|
||||
category = str(plan.get("trainingPlanCategory", "")).upper()
|
||||
if category == "FBT_ADAPTIVE":
|
||||
detail = api.get_adaptive_training_plan_by_id(pid)
|
||||
else:
|
||||
detail = api.get_training_plan_by_id(pid)
|
||||
if dump_json:
|
||||
out = Path("debug") / f"coach_plan_{pid}_{target_date.isoformat()}.json"
|
||||
dump_redacted_json(out, detail)
|
||||
print(f"Redacted plan detail written to {out}")
|
||||
|
||||
workout = best_workout_for_date(detail, target_date)
|
||||
if workout is None:
|
||||
workout = _fetch_fbt_workout_for_date(api, detail, target_date)
|
||||
date_matches = find_date_matches(detail, target_date)
|
||||
workout_like = find_workout_like_objects(detail)
|
||||
print(f"\nPlan {pid}: {plan.get('name')}")
|
||||
print(f"Date matches for {target_date.isoformat()}: {len(date_matches)}")
|
||||
print(f"Workout-like objects with workoutSegments/workoutSteps: {len(workout_like)}")
|
||||
if workout:
|
||||
any_workout = True
|
||||
print(summarize_workout(workout))
|
||||
if dump_json:
|
||||
out = Path("debug") / f"coach_workout_{target_date.isoformat()}.json"
|
||||
dump_redacted_json(out, workout)
|
||||
print(f"Redacted matched workout JSON written to {out}")
|
||||
if target_date == date.today():
|
||||
today_out = Path("debug") / "coach_workout_today.json"
|
||||
dump_redacted_json(today_out, workout)
|
||||
print(f"Redacted matched workout JSON also written to {today_out}")
|
||||
else:
|
||||
print("No parseable workoutSegments/workoutSteps found for this date.")
|
||||
|
||||
return 0 if any_workout else 1
|
||||
|
||||
|
||||
def command_coach_endpoints(target_date: date, dump_json: bool) -> int:
|
||||
api = login()
|
||||
plans = extract_training_plans(api.get_training_plans())
|
||||
candidates = [plan for plan in plans if is_adaptive_plan(plan)]
|
||||
if not candidates:
|
||||
print("No Garmin Coach/adaptive plan was identifiable in get_training_plans().")
|
||||
return 1
|
||||
|
||||
report: dict[str, Any] = {"date": target_date.isoformat(), "plans": []}
|
||||
found_workout_like = False
|
||||
for plan in candidates:
|
||||
pid = plan_id(plan)
|
||||
if pid is None:
|
||||
continue
|
||||
detail = (
|
||||
api.get_adaptive_training_plan_by_id(pid)
|
||||
if str(plan.get("trainingPlanCategory", "")).upper() == "FBT_ADAPTIVE"
|
||||
else api.get_training_plan_by_id(pid)
|
||||
)
|
||||
workout = task_workout_for_date(detail, target_date)
|
||||
if workout is None:
|
||||
print(f"Plan {pid}: no taskWorkout found for {target_date.isoformat()}.")
|
||||
continue
|
||||
|
||||
uuid = workout.get("workoutUuid")
|
||||
calendar_id = _calendar_item_id_for_workout(api, target_date, pid, uuid)
|
||||
endpoints = _coach_endpoint_candidates(pid, uuid, calendar_id, target_date)
|
||||
plan_report: dict[str, Any] = {
|
||||
"plan_id": pid,
|
||||
"plan_name": plan.get("name"),
|
||||
"workout_name": workout.get("workoutName"),
|
||||
"workout_uuid": uuid,
|
||||
"calendar_item_id": calendar_id,
|
||||
"endpoints": [],
|
||||
}
|
||||
report["plans"].append(plan_report)
|
||||
|
||||
print(f"\nPlan {pid}: {plan.get('name')}")
|
||||
print(f"Workout: {workout.get('workoutName')} uuid={uuid} calendar_id={calendar_id}")
|
||||
for endpoint in endpoints:
|
||||
result = _capture_endpoint(api, endpoint)
|
||||
workout_like_count = len(find_workout_like_objects(result))
|
||||
if workout_like_count:
|
||||
found_workout_like = True
|
||||
plan_report["endpoints"].append(
|
||||
{
|
||||
"endpoint": endpoint,
|
||||
"result": result,
|
||||
"workout_like_count": workout_like_count,
|
||||
}
|
||||
)
|
||||
status = "error" if _is_error(result) else "ok"
|
||||
keys = ", ".join(result.keys()) if isinstance(result, dict) else type(result).__name__
|
||||
print(f" - {status}: {endpoint} workout_like={workout_like_count} keys={keys}")
|
||||
|
||||
if dump_json:
|
||||
out = Path("debug") / f"coach_endpoint_probe_{target_date.isoformat()}.json"
|
||||
dump_redacted_json(out, report)
|
||||
print(f"\nRedacted Coach endpoint probe written to {out}")
|
||||
|
||||
if found_workout_like:
|
||||
print("\nAt least one candidate endpoint returned workout-like step data.")
|
||||
return 0
|
||||
print("\nNo candidate endpoint returned workoutSegments/workoutSteps.")
|
||||
return 1
|
||||
|
||||
|
||||
def command_workouts(limit: int, dump_json: bool) -> int:
|
||||
api = login()
|
||||
workouts = api.get_workouts(limit=limit)
|
||||
print(f"Normal workouts returned: {len(workouts)}")
|
||||
for workout in workouts:
|
||||
print(
|
||||
" - id={id} sport={sport} name={name}".format(
|
||||
id=workout.get("workoutId"),
|
||||
sport=(workout.get("sportType") or {}).get("sportTypeKey"),
|
||||
name=workout.get("workoutName"),
|
||||
)
|
||||
)
|
||||
if dump_json:
|
||||
out = Path("debug") / "workouts.json"
|
||||
dump_redacted_json(out, workouts)
|
||||
print(f"Redacted workout list written to {out}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_workout(workout_id: str, dump_json: bool) -> int:
|
||||
api = login()
|
||||
workout = api.get_workout_by_id(workout_id)
|
||||
print(summarize_workout(workout))
|
||||
if dump_json:
|
||||
out = Path("debug") / f"workout_{workout_id}.json"
|
||||
dump_redacted_json(out, workout)
|
||||
print(f"Redacted workout JSON written to {out}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_generated(prefix: str, limit: int, delete_id: str | None, confirm: bool) -> int:
|
||||
api = login()
|
||||
workouts = api.get_workouts(limit=limit)
|
||||
generated = generated_workouts(workouts, prefix)
|
||||
print(f"Generated workouts with prefix {prefix!r}: {len(generated)}")
|
||||
for workout in generated:
|
||||
print(
|
||||
" - id={id} sport={sport} name={name}".format(
|
||||
id=workout.get("workoutId") or workout.get("id"),
|
||||
sport=(workout.get("sportType") or {}).get("sportTypeKey"),
|
||||
name=workout.get("workoutName"),
|
||||
)
|
||||
)
|
||||
|
||||
if delete_id is None:
|
||||
return 0
|
||||
if not confirm:
|
||||
print("\nRefusing to delete without --confirm.")
|
||||
return 1
|
||||
|
||||
selected = find_generated_workout(workouts, delete_id, prefix)
|
||||
if selected is None:
|
||||
print(f"\nRefusing to delete {delete_id}: not found among generated workouts.")
|
||||
return 1
|
||||
|
||||
name = selected.get("workoutName")
|
||||
api.delete_workout(delete_id)
|
||||
print(f"\nDeleted generated workout template {delete_id}: {name}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_generated_calendar(
|
||||
target_date: date,
|
||||
prefix: str,
|
||||
unschedule_id: str | None,
|
||||
dump_json: bool,
|
||||
confirm: bool,
|
||||
) -> int:
|
||||
api = login()
|
||||
calendar_data = api.get_scheduled_workouts(target_date.year, target_date.month)
|
||||
generated = generated_calendar_entries(calendar_data, prefix)
|
||||
print(
|
||||
f"Generated calendar entries with prefix {prefix!r} in "
|
||||
f"{target_date.year:04d}-{target_date.month:02d}: {len(generated)}"
|
||||
)
|
||||
for entry in generated:
|
||||
print(
|
||||
" - scheduled_id={id} date={date} name={name}".format(
|
||||
id=calendar_entry_id(entry) or "<unknown>",
|
||||
date=calendar_entry_date(entry) or "<unknown>",
|
||||
name=calendar_entry_name(entry) or "<unnamed>",
|
||||
)
|
||||
)
|
||||
|
||||
if dump_json:
|
||||
out = Path("debug") / f"generated_calendar_{target_date:%Y_%m}.json"
|
||||
dump_redacted_json(out, calendar_data)
|
||||
print(f"Redacted calendar JSON written to {out}")
|
||||
|
||||
if unschedule_id is None:
|
||||
return 0
|
||||
if not confirm:
|
||||
print("\nRefusing to unschedule without --confirm.")
|
||||
return 1
|
||||
|
||||
selected = find_generated_calendar_entry(calendar_data, unschedule_id, prefix)
|
||||
if selected is None:
|
||||
print(
|
||||
f"\nRefusing to unschedule {unschedule_id}: "
|
||||
"not found among generated calendar entries."
|
||||
)
|
||||
return 1
|
||||
|
||||
api.unschedule_workout(unschedule_id)
|
||||
print(
|
||||
"\nUnscheduled generated calendar entry {id}: {name}".format(
|
||||
id=unschedule_id,
|
||||
name=calendar_entry_name(selected) or "<unnamed>",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_dummy(target_date: date, dry_run: bool, schedule: bool) -> int:
|
||||
payload = build_dummy_cycling_workout()
|
||||
validation_errors = validate_workout_payload(payload)
|
||||
if validation_errors:
|
||||
print("Dummy workout payload failed local validation:")
|
||||
for error in validation_errors:
|
||||
print(f" - {error}")
|
||||
return 1
|
||||
print("Dummy workout payload passed local validation.")
|
||||
if dry_run or not schedule:
|
||||
print("Dry run: dummy cycling workout JSON follows. Nothing was uploaded.")
|
||||
print_json(payload)
|
||||
if not schedule:
|
||||
print("\nPass --schedule to upload and schedule this dummy workout.")
|
||||
return 0
|
||||
|
||||
api = login()
|
||||
result: dict[str, Any] = api.upload_workout(payload)
|
||||
workout_id = result.get("workoutId") or result.get("id")
|
||||
if workout_id is None:
|
||||
print("Upload returned no workoutId. Raw response:")
|
||||
print_json(result)
|
||||
return 1
|
||||
schedule_result = api.schedule_workout(workout_id, target_date.isoformat())
|
||||
print(f"Uploaded dummy workout ID: {workout_id}")
|
||||
print("Schedule response:")
|
||||
print_json(schedule_result)
|
||||
print_edge_check_instructions(target_date)
|
||||
return 0
|
||||
|
||||
|
||||
def print_edge_check_instructions(target_date: date) -> None:
|
||||
print(
|
||||
f"""
|
||||
Next checks on the Edge 1030 after Garmin Connect sync:
|
||||
1. Sync the Edge 1030 with Garmin Connect / Garmin Express.
|
||||
2. Check Training > Workouts for the dummy cycling workout.
|
||||
3. Check Training > Training Plan > calendar icon > {target_date.isoformat()}.
|
||||
4. Start a normal course/navigation route, then start the workout and verify both can run.
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _capture(label: str, report: dict[str, Any], func: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
result = func()
|
||||
except Exception as exc: # noqa: BLE001 - report should continue through partial failures
|
||||
result = {"error": f"{type(exc).__name__}: {exc}"}
|
||||
report[label] = result
|
||||
return result
|
||||
|
||||
|
||||
def _capture_endpoint(api: Any, endpoint: str) -> Any:
|
||||
try:
|
||||
if endpoint.startswith("/gc-api/"):
|
||||
return gc_api_get(api, endpoint)
|
||||
return api.connectapi(endpoint)
|
||||
except Exception as exc: # noqa: BLE001 - endpoint probing should continue
|
||||
return {"error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
|
||||
def _calendar_item_id_for_workout(
|
||||
api: Any, target_date: date, plan_id_value: int, workout_uuid: Any
|
||||
) -> str | None:
|
||||
if workout_uuid is None:
|
||||
return None
|
||||
calendar_data = _capture_endpoint(
|
||||
api, f"/calendar-service/year/{target_date.year}/month/{target_date.month - 1}"
|
||||
)
|
||||
if _is_error(calendar_data):
|
||||
return None
|
||||
for match in find_date_matches(calendar_data, target_date):
|
||||
if (
|
||||
match.get("itemType") == "fbtAdaptiveWorkout"
|
||||
and str(match.get("trainingPlanId")) == str(plan_id_value)
|
||||
and str(match.get("workoutUuid")) == str(workout_uuid)
|
||||
):
|
||||
item_id = match.get("id")
|
||||
return str(item_id) if item_id is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def _coach_endpoint_candidates(
|
||||
plan_id_value: int, workout_uuid: Any, calendar_item_id: str | None, target_date: date
|
||||
) -> list[str]:
|
||||
endpoints = [
|
||||
f"/gc-api/workout-service/fbt-adaptive/{workout_uuid}"
|
||||
if workout_uuid is not None
|
||||
else "",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts/{target_date.isoformat()}",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks/{target_date.isoformat()}",
|
||||
]
|
||||
endpoints = [endpoint for endpoint in endpoints if endpoint]
|
||||
if workout_uuid is not None:
|
||||
uuid = str(workout_uuid)
|
||||
endpoints.extend(
|
||||
[
|
||||
f"/workout-service/fbt-adaptive/{uuid}",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workout/{uuid}",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/workouts/{uuid}",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/task/{uuid}",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/{plan_id_value}/tasks/{uuid}",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/workout/{uuid}",
|
||||
f"/trainingplan-service/trainingplan/fbt-adaptive/workouts/{uuid}",
|
||||
f"/trainingplan-service/trainingplan/workout/{uuid}",
|
||||
f"/workout-service/workout/{uuid}",
|
||||
f"/workout-service/workouts/{uuid}",
|
||||
f"/workout-service/workout/uuid/{uuid}",
|
||||
f"/workout-service/workouts/uuid/{uuid}",
|
||||
]
|
||||
)
|
||||
if calendar_item_id is not None:
|
||||
endpoints.extend(
|
||||
[
|
||||
f"/workout-service/schedule/{calendar_item_id}",
|
||||
f"/calendar-service/{calendar_item_id}",
|
||||
f"/calendar-service/calendar/{calendar_item_id}",
|
||||
f"/calendar-service/event/{calendar_item_id}",
|
||||
]
|
||||
)
|
||||
return list(dict.fromkeys(endpoints))
|
||||
|
||||
|
||||
def _fetch_fbt_workout_for_date(
|
||||
api: Any, plan_detail: Any, target_date: date
|
||||
) -> dict[str, Any] | None:
|
||||
workout = task_workout_for_date(plan_detail, target_date)
|
||||
if workout is None:
|
||||
return None
|
||||
workout_uuid = workout.get("workoutUuid")
|
||||
if not workout_uuid:
|
||||
return None
|
||||
try:
|
||||
return get_fbt_adaptive_workout(api, str(workout_uuid))
|
||||
except Exception as exc: # noqa: BLE001 - shown as probe output
|
||||
print(
|
||||
f"FBT adaptive workout detail fetch failed for {workout_uuid}: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_error(value: Any) -> bool:
|
||||
return isinstance(value, dict) and isinstance(value.get("error"), str)
|
||||
|
||||
|
||||
def _print_section(title: str) -> None:
|
||||
print(f"\n== {title} ==")
|
||||
|
||||
|
||||
def _plan_summary(plan: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": plan.get("trainingPlanId") or plan.get("planId") or plan.get("id"),
|
||||
"name": plan.get("name"),
|
||||
"category": plan.get("trainingPlanCategory"),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user