mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 11:43:09 +02:00
- Refactor RunnerUp verification: extract RunnerUpDbMixin (_runnerup_db.py), split _scan_and_fill_week_runnerup into a helper _try_fill_runnerup_for_date to keep cyclomatic complexity ≤10 - Generalise TCX lookup to any date in the ISO week (was today-only); all gap days Mon→today auto-filled on every startup and 08:30 timer firing - Add _adjust_shutdown_time_by(): +1h per extra workout beyond the 4-workout minimum, capped at midnight (hour=24) - Add _shutdown_base.py: daily reset of shutdown config to a stored base so the bonus doesn't silently accumulate across days - Add _extra_benefits.py: streak tracking, skip credits (earn (n-4) credits for 5+ workout weeks), early-bird extension to 09:00 for eligible weeks - Add --status mode (_status.py): non-locking CLI view showing per-day breakdown (✓/✗), RunnerUp auto-scan, bonus status, shutdown time, streak, skip credits, and early-bird status - Hook carrot into _check_non_verify_exits: bonus applied whenever auto-fill pushes weekly count above the minimum - Pass all pre-commit hooks (ruff, mypy, pylint, bandit, shellcheck, codespell, max-file-length); 508 tests at 100% branch coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017auyHmf2ZwQcDAwXaSo7KX
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""RunnerUp run auto-verification via ADB (file-based path + shared validation).
|
|
|
|
File-based (no root, works over WiFi): reads per-activity TCX files that
|
|
RunnerUp's File Synchronizer writes to ``/sdcard/Documents/RunnerUp/``.
|
|
Root DB fallback lives in ``_runnerup_db.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
import shutil
|
|
import tempfile
|
|
from typing import Any
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from gatelock.log_integrity import compute_entry_hmac
|
|
|
|
from screen_locker._constants import (
|
|
MIN_RUN_DISTANCE_KM,
|
|
MIN_RUN_DURATION_MINUTES,
|
|
RUNNERUP_ACCEPTED_SPORTS,
|
|
RUNNERUP_EXPORT_DIRS,
|
|
)
|
|
from screen_locker._runnerup_db import RunnerUpDbMixin
|
|
from screen_locker._time_check import check_clock_skew
|
|
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_SPORT_NAMES: dict[int, str] = {
|
|
0: "Running",
|
|
1: "Biking",
|
|
2: "Other",
|
|
3: "Orienteering",
|
|
4: "Walking",
|
|
5: "Treadmill",
|
|
6: "Gym",
|
|
7: "Stationary Bike",
|
|
}
|
|
|
|
# TCX uses sport name strings; map back to integer codes for unified validation.
|
|
_TCX_SPORT_TO_INT: dict[str, int] = {v: k for k, v in _SPORT_NAMES.items()}
|
|
|
|
# TCX XML namespace used by Garmin/RunnerUp.
|
|
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
|
|
|
|
|
class RunnerUpVerificationMixin(RunnerUpDbMixin):
|
|
"""Mixin providing RunnerUp-based workout verification via ADB."""
|
|
|
|
# ------------------------------------------------------------------
|
|
# File-based path (no root required)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
|
|
"""Return adb paths of RunnerUp TCX exports for the given date, or empty list.
|
|
|
|
Args:
|
|
date_str: ISO date string in ``YYYY-MM-DD`` format matched against
|
|
TCX filenames (``RunnerUp_YYYY-MM-DD-HH-MM-SS_xxx.tcx``).
|
|
"""
|
|
found: list[str] = []
|
|
for dirpath in RUNNERUP_EXPORT_DIRS:
|
|
ok, out = self._run_adb(["shell", "ls", dirpath])
|
|
if not ok or not out.strip():
|
|
continue
|
|
for raw in out.strip().splitlines():
|
|
name = raw.strip()
|
|
if date_str in name and name.endswith(".tcx"):
|
|
remote = f"{dirpath}/{name}"
|
|
if remote not in found:
|
|
found.append(remote)
|
|
return found
|
|
|
|
def _pull_and_parse_tcx(self, remote_path: str) -> dict[str, Any] | None:
|
|
"""Pull a remote TCX file and parse it. Returns activity dict or None."""
|
|
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
|
|
local_path = str(Path(tmp_dir) / "activity.tcx")
|
|
try:
|
|
ok, _ = self._run_adb(["pull", remote_path, local_path])
|
|
if not ok or not Path(local_path).exists():
|
|
_logger.info("Failed to pull TCX file: %s", remote_path)
|
|
return None
|
|
return self._parse_tcx(local_path)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
|
|
"""Parse a local TCX file and return activity summary dict.
|
|
|
|
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
|
multi-segment runs (pause/resume) are counted in full.
|
|
"""
|
|
try:
|
|
tree = ET.parse(tcx_path)
|
|
except ET.ParseError as exc:
|
|
_logger.info("TCX parse error in %s: %s", tcx_path, exc)
|
|
return None
|
|
|
|
root = tree.getroot()
|
|
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
|
if activity is None:
|
|
_logger.info("No Activity element in TCX file")
|
|
return None
|
|
|
|
sport_str = activity.get("Sport", "")
|
|
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
|
|
|
total_seconds = 0.0
|
|
total_distance = 0.0
|
|
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
|
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
|
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
|
if t_elem is not None and t_elem.text:
|
|
total_seconds += float(t_elem.text)
|
|
if d_elem is not None and d_elem.text:
|
|
total_distance += float(d_elem.text)
|
|
|
|
return {
|
|
"sport": sport_int,
|
|
"duration_seconds": int(total_seconds),
|
|
"distance_m": total_distance,
|
|
}
|
|
|
|
def _verify_runnerup_via_files(self) -> tuple[str, str] | None:
|
|
"""Try to verify today's run via TCX export files.
|
|
|
|
Returns ``(status, message)`` if a today's file was found (even if it
|
|
fails validation), or ``None`` if no today's file exists at all
|
|
(caller should try the root DB path instead).
|
|
"""
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
exports = self._find_runnerup_exports_for_date(today)
|
|
if not exports:
|
|
return None
|
|
|
|
# Try each file; return the best result (verified > validation error).
|
|
best: tuple[str, str] | None = None
|
|
for remote in exports:
|
|
data = self._pull_and_parse_tcx(remote)
|
|
if data is None:
|
|
continue
|
|
status, msg = self._validate_runnerup_data(data)
|
|
if status == "verified":
|
|
return status, msg
|
|
if best is None:
|
|
best = (status, msg)
|
|
|
|
# All files found but none passed validation.
|
|
return best or (
|
|
"not_verified",
|
|
"RunnerUp TCX export found but could not be read",
|
|
)
|
|
|
|
def _try_fill_runnerup_for_date(self, date_str: str, logs: dict[str, Any]) -> bool:
|
|
"""Try to fill one date gap from RunnerUp TCX exports, mutating logs in-place.
|
|
|
|
Returns True if a verified entry was written for ``date_str``.
|
|
"""
|
|
existing = logs.get(date_str, {})
|
|
if isinstance(existing, dict):
|
|
wtype = existing.get("workout_data", {}).get("type", "")
|
|
if wtype in COUNTED_WORKOUT_TYPES:
|
|
return False
|
|
for remote in self._find_runnerup_exports_for_date(date_str):
|
|
data = self._pull_and_parse_tcx(remote)
|
|
if data is None:
|
|
continue
|
|
status, msg = self._validate_runnerup_data(data)
|
|
if status != "verified":
|
|
continue
|
|
entry: dict[str, Any] = {
|
|
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
|
"workout_data": {
|
|
"type": "runnerup_verified",
|
|
"source": f"Auto-scanned: {msg}",
|
|
"distance_km": round(data["distance_m"] / 1000, 2),
|
|
"duration_minutes": round(data["duration_seconds"] / 60, 1),
|
|
},
|
|
}
|
|
signature = compute_entry_hmac(entry)
|
|
if signature is not None:
|
|
entry["hmac"] = signature
|
|
logs[date_str] = entry
|
|
_logger.info("Auto-filled RunnerUp entry for %s: %s", date_str, msg)
|
|
return True
|
|
return False
|
|
|
|
def _scan_and_fill_week_runnerup(self, log_file: Path) -> int:
|
|
"""Scan the current ISO week for RunnerUp TCX gaps and fill them.
|
|
|
|
Returns the count of newly filled entries (0 if phone not connected).
|
|
"""
|
|
if not self._has_adb_device():
|
|
_logger.info(
|
|
"Phone not connected; skipping auto-scan for past RunnerUp exports."
|
|
)
|
|
return 0
|
|
|
|
now = datetime.now(tz=timezone.utc).astimezone()
|
|
today = now.date()
|
|
week_start = today - timedelta(days=today.weekday())
|
|
|
|
try:
|
|
with log_file.open() as f:
|
|
logs: dict[str, Any] = json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
logs = {}
|
|
|
|
filled = 0
|
|
current = week_start
|
|
while current <= today:
|
|
if self._try_fill_runnerup_for_date(current.strftime("%Y-%m-%d"), logs):
|
|
filled += 1
|
|
current += timedelta(days=1)
|
|
|
|
if filled > 0:
|
|
try:
|
|
with log_file.open("w") as f:
|
|
json.dump(logs, f, indent=2)
|
|
except OSError as exc:
|
|
_logger.warning("Failed to write workout log after scan: %s", exc)
|
|
return 0
|
|
|
|
return filled
|
|
|
|
# ------------------------------------------------------------------
|
|
# Shared validation
|
|
# ------------------------------------------------------------------
|
|
|
|
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
|
|
"""Validate a RunnerUp activity against configured thresholds.
|
|
|
|
Returns ``(status, message)`` following the same contract as
|
|
``PhoneVerificationMixin._verify_phone_workout``.
|
|
"""
|
|
sport = data["sport"]
|
|
if sport not in RUNNERUP_ACCEPTED_SPORTS:
|
|
sport_name = _SPORT_NAMES.get(sport, f"unknown({sport})")
|
|
return (
|
|
"wrong_sport",
|
|
f"Activity type '{sport_name}' doesn't count as a qualifying run",
|
|
)
|
|
|
|
duration_min = data["duration_seconds"] / 60
|
|
if duration_min < MIN_RUN_DURATION_MINUTES:
|
|
msg = (
|
|
f"Run was {duration_min:.0f} min — need {MIN_RUN_DURATION_MINUTES}+ min"
|
|
)
|
|
return "too_short", msg
|
|
|
|
distance_km = data["distance_m"] / 1000
|
|
if distance_km < MIN_RUN_DISTANCE_KM:
|
|
msg = f"Run was {distance_km:.1f} km — need {MIN_RUN_DISTANCE_KM:.0f}+ km"
|
|
return "too_short", msg
|
|
|
|
sport_name = _SPORT_NAMES.get(sport, str(sport))
|
|
return (
|
|
"verified",
|
|
f"{sport_name}: {distance_km:.1f} km in {duration_min:.0f} min",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Main entry point
|
|
# ------------------------------------------------------------------
|
|
|
|
def _verify_runnerup_workout(self) -> tuple[str, str]:
|
|
"""Verify today's run via RunnerUp.
|
|
|
|
Tries TCX file exports first (no root, works over WiFi); falls back to
|
|
root DB pull if no today's files are found.
|
|
|
|
Status values: ``verified | not_verified | no_phone | too_short |
|
|
wrong_sport | clock_tampered``.
|
|
"""
|
|
skew_ok, skew_msg = check_clock_skew()
|
|
if not skew_ok:
|
|
return "clock_tampered", skew_msg
|
|
|
|
if not self._has_adb_device():
|
|
return (
|
|
"no_phone",
|
|
"Phone not connected — plug in via USB or enable wireless ADB",
|
|
)
|
|
|
|
# Path 1: file-based (no root needed).
|
|
file_result = self._verify_runnerup_via_files()
|
|
if file_result is not None:
|
|
_logger.info("RunnerUp file-based result: %s", file_result[0])
|
|
return file_result
|
|
|
|
# Path 2: root DB pull (fallback when no export files found yet).
|
|
_logger.info("No TCX exports found today; trying root DB pull...")
|
|
return self._verify_runnerup_via_db()
|