mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
_runnerup_verification.py (312) -> _runnerup_tcx.py pull + parse TCX
_runnerup_backfill.py fill the log for a
date or a week
_ui_widgets.py (287) -> _ui_form_fields.py labelled fields,
focus, disable_paste
_sick_dialog.py (278) -> _sick_commitment.py next-day commitment
_ui_form_fields also takes disable_paste, since the fields are what use it;
_ui_widgets re-exports it for _manual_workout_widgets and _sick_dialog.
Two things had to move with the code rather than being left behind:
- conftest's _TK_MODULES gains _ui_form_fields, or its tkinter calls reach a
real display instead of the mock.
- pyproject's S314 waiver follows the ET.parse call it covers into
_runnerup_tcx.py. Same approved suppression, new home -- not a new one.
Test count 977 -> 978: _ui_form_fields is a new lock-surface module, so
test_no_popup_widgets safety-checks it too. 100% branch coverage held; pylint
back at 8.70 after fixing a genuinely undefined WidgetGroup annotation the
split exposed. `screen_lock --status` unchanged. Gate: 41 -> 28 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
97 lines
3.2 KiB
Python
97 lines
3.2 KiB
Python
"""Pulling a RunnerUp TCX export off the phone and parsing it.
|
|
|
|
Split out of :mod:`screen_locker._runnerup_verification` to keep every file
|
|
under the 250-line cap. Composed back into ``RunnerUpVerificationMixin``
|
|
there, so callers see no change.
|
|
|
|
TCX is the no-root path: RunnerUp writes these exports to shared storage, so
|
|
they can be read without touching the app's private database.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
import shutil
|
|
import tempfile
|
|
from typing import Any
|
|
import xml.etree.ElementTree as ET
|
|
|
|
# TCX XML namespace used by Garmin/RunnerUp.
|
|
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
|
|
|
_SPORT_NAMES: dict[int, str] = {
|
|
0: "Running",
|
|
1: "Cycling",
|
|
2: "Other",
|
|
3: "Orienteering",
|
|
4: "Walking",
|
|
5: "Skiing",
|
|
6: "Swimming",
|
|
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()}
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RunnerUpTcxMixin:
|
|
"""Pulls and parses RunnerUp's TCX exports over adb."""
|
|
|
|
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.warning(
|
|
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
|
"be verified and will not count",
|
|
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,
|
|
}
|