diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8a78469 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 100 + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..db4f84b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default owner for everything in the repo +* @konverga diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8a08f16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Bug Report +description: Report a problem with KayakFit +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! + + - type: textarea + id: description + attributes: + label: Description + description: What happened? What did you expect to happen instead? + validations: + required: true + + - type: input + id: os + attributes: + label: Operating System + placeholder: "e.g. macOS 15.2, Windows 11" + validations: + required: true + + - type: input + id: python-version + attributes: + label: Python version + placeholder: "e.g. 3.14.5" + validations: + required: false + + - type: input + id: device + attributes: + label: Ergometer / firmware version + placeholder: "e.g. KayakFirst Bull, firmware X.Y" + validations: + required: false + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Paste logs from the app logger, if available. This will be auto-formatted as code. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a3a30d8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions / Discussion + url: https://github.com/konverga/KayakFit/discussions + about: Ask general questions or discuss ideas here instead of opening an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..57fffb1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature Request +description: Suggest an idea or improvement for KayakFit +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What problem does this solve, or what's currently missing? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What would you like to happen? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any other approaches you thought about? + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional context + description: Screenshots, related issues, links, etc. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9b73378 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +## Summary + + + +## Changes + +- +- + +## Testing + + + +- [ ] Tested locally against the ergometer +- [ ] Existing tests pass +- [ ] Added/updated tests where relevant + +## Checklist + +- [ ] `ruff check .` and `mypy . --strict` pass with no errors (CI's `lint` job enforces this) +- [ ] Code follows project style (type hints, docstrings, double quotes) +- [ ] No secrets, tokens, or personal data included +- [ ] Updated README/docs if behavior changed +- [ ] Linked related issue(s), if any + +Closes # diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fdd23c8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 1 + groups: + python-dependencies: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 1 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..e141bf5 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,207 @@ +name: Build & Release + +# Builds the macOS .app (Apple Silicon/arm64) and the Windows .exe with +# PyInstaller and attaches them to a GitHub Release. +# +# Triggers: +# - push a tag like "1.2.0" -> builds + creates/updates that release +# - manual run (Actions tab) -> builds only, no release (for testing) + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: {} + +permissions: + contents: read + +# Serialize release builds per ref, but never cancel an in-flight one: a +# half-finished release publish should complete rather than be interrupted. +concurrency: + group: build-release-${{ github.ref }} + cancel-in-progress: false + +env: + # The interpreter every build leg uses. uv downloads a managed CPython for it. + UV_PYTHON: "3.14" + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify release tag is on main + if: startsWith(github.ref, 'refs/tags/') + run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Validate source and tests + run: | + uv sync --locked + uv run ruff check . + uv run mypy . --strict + uv run pytest + + build-macos: + needs: validate + runs-on: macos-14 + timeout-minutes: 35 + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Use the official CPython build that includes a working Tcl/Tk. + - name: Set up Python with Tk + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + run: | + uv sync \ + --locked \ + --no-default-groups \ + --group build \ + --python "$(which python3.14)" + + - name: Determine version + shell: bash + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "KAYAKFIT_VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV" + else + echo "KAYAKFIT_VERSION=0.0.0-dev" >> "$GITHUB_ENV" + fi + + - name: Build with PyInstaller + run: | + uv run \ + --python "$(which python3.14)" \ + pyinstaller KayakFit.spec \ + --distpath dist-mac \ + --workpath build-mac + + - name: Verify Tcl/Tk bundle + shell: bash + run: | + APP="dist-mac/KayakFit.app" + + required=( + "Contents/Frameworks/Tcl" + "Contents/Frameworks/Tk" + "Contents/Resources/tcl9" + "Contents/Resources/_tcl_data" + "Contents/Resources/_tk_data" + ) + + missing=0 + for path in "${required[@]}"; do + if [[ ! -e "$APP/$path" ]]; then + echo "::error::Missing $path" + missing=1 + fi + done + + if [[ "$missing" -ne 0 ]]; then + exit 1 + fi + + echo "✓ Tcl/Tk bundle verified." + + - name: Package .app bundle + run: | + cd dist-mac + ditto -c -k --sequesterRsrc --keepParent \ + KayakFit.app \ + ../KayakFit-macOS-arm64.zip + + - uses: actions/upload-artifact@v7 + with: + name: KayakFit-macOS-arm64 + path: KayakFit-macOS-arm64.zip + if-no-files-found: error + retention-days: 7 + + build-windows: + needs: validate + runs-on: windows-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + # Runtime deps + the PyInstaller build group, but not the dev tools + # (ruff/mypy/pytest) — they are never part of the shipped bundle. + run: uv sync --locked --no-default-groups --group build + + - name: Determine version + shell: bash + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "KAYAKFIT_VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV" + else + echo "KAYAKFIT_VERSION=0.0.0-dev" >> "$GITHUB_ENV" + fi + + - name: Build with PyInstaller + run: uv run pyinstaller KayakFit.spec --distpath dist-win --workpath build-win + + - name: Package exe + run: Compress-Archive -Path dist-win\KayakFit.exe -DestinationPath KayakFit-Windows.zip + + - uses: actions/upload-artifact@v7 + with: + name: KayakFit-Windows + path: KayakFit-Windows.zip + if-no-files-found: error + retention-days: 7 + + release: + needs: [build-macos, build-windows] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + path: artifacts + + - name: Generate release checksums + working-directory: artifacts + run: | + sha256sum \ + KayakFit-macOS-arm64/KayakFit-macOS-arm64.zip \ + KayakFit-Windows/KayakFit-Windows.zip \ + > SHA256SUMS.txt + + - name: Attach builds to release + uses: softprops/action-gh-release@v3 + with: + generate_release_notes: true + files: | + artifacts/KayakFit-macOS-arm64/KayakFit-macOS-arm64.zip + artifacts/KayakFit-Windows/KayakFit-Windows.zip + artifacts/SHA256SUMS.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4035b8c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,117 @@ +name: CI + +# Gates every PR into main (and every push to main) on more than the PR +# checklist: a lint + type-check job (ruff + mypy, on Linux) and the test suite +# (on the two platforms the app actually ships on: macOS and Windows). All jobs +# must pass before a PR can merge. +# +# Dependencies are managed with uv: `uv sync` installs exactly what uv.lock +# pins (creating the lock on the fly if it is missing), so every job gets the +# same tool + stub versions a developer has locally. `uv run ` then runs +# inside that environment. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# CI only checks out the code, then lints/type-checks and runs tests — it never +# writes to the repo, so drop the default token down to read-only. +permissions: + contents: read + +# Supersede in-flight runs for the same ref (e.g. a force-push to a PR) instead +# of letting stale runs finish. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # requires-python in pyproject.toml sets the floor (3.14); pin the exact + # interpreter CI, local dev and the release build all use here. uv downloads a + # managed CPython for it automatically. + UV_PYTHON: "3.14" + +jobs: + # Lint + static type check. Runs on Linux only (the checks are + # platform-independent). `uv sync` installs the dev dependency-group (ruff, + # mypy and the type-stub packages), so CI sees the same tool + stub versions a + # developer does. Both commands must pass with zero errors; a failure blocks + # the merge (see CONTRIBUTING.md sections 3-4 and the branch-protection note + # in 7). + lint: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + # Fail if uv.lock has drifted from pyproject.toml (e.g. a dependency or + # requires-python bump that wasn't followed by `uv lock`). --check asserts + # the lock is up to date without modifying it, so a stale committed lock + # can never slip through on the back of uv sync's silent auto-relock. + - name: uv.lock is in sync with pyproject.toml + run: uv lock --check + + - name: Install dependencies + run: uv sync --locked + + - name: Ruff (lint + import order) + run: uv run ruff check . + + - name: Mypy (static type check) + run: uv run mypy . --strict + + # requirements.txt is a *generated* pip fallback exported from uv.lock + # (see its header). Regenerate in place with the exact same command that + # produced the committed file (uv embeds the -o path in the header, so + # exporting to a different path always diffs) and fail on any change, + # so the committed file can never silently drift from the lock. + - name: requirements.txt matches uv.lock + run: | + uv export --format requirements-txt --no-hashes --all-groups -o requirements.txt + git diff --exit-code requirements.txt + + test: + strategy: + fail-fast: false + matrix: + os: [macos-14, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Homebrew's Python on macOS runners does not include the _tkinter + # extension. Use the official CPython build so GUI modules can be + # imported during test collection, matching the macOS release build. + - name: Set up Python with Tk + if: runner.os == 'macOS' + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies (macOS) + if: runner.os == 'macOS' + run: uv sync --locked --python "$(which python3.14)" + + - name: Install dependencies (Windows) + if: runner.os == 'Windows' + run: uv sync --locked + + - name: Run test suite + run: uv run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d0a9e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +workouts/ +__pycache__/ +*.py[cod] +__removed +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.python-version +.DS_Store +.venv/ +.env +.env.* +!.env.example +build/ +dist/ +build-mac/ +dist-mac/ +build-win/ +dist-win/ +__*.json +__*.txt +_test*/ +docs/_* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9b917e5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,325 @@ +# Contributing to KayakFit + +This document describes how the KayakFit repo is set up on GitHub and the +workflow every contributor (including solo maintainers) should follow: making +changes, opening pull requests, and cutting releases. + +--- + +## 1. Overview: what lives where + +| File / path | Purpose | +|---|---| +| `.github/workflows/ci.yml` | Runs the lint + type checks (`ruff`/`mypy`, on Linux) and the test suite (macOS + Windows) automatically on every PR and push to `main`. All jobs must pass to merge. | +| `.github/workflows/build-release.yml` | Builds the macOS `.app` (Apple Silicon) and Windows `.exe` and attaches them to a GitHub Release. | +| `.github/CODEOWNERS` | Who is required to review changes. | +| `.github/PULL_REQUEST_TEMPLATE.md` | Checklist that pre-fills every new PR. | +| `.github/ISSUE_TEMPLATE/` | Structured forms for bug reports and feature requests. | +| `KayakFit.spec` | PyInstaller build spec used by both local builds and CI. | +| `pyproject.toml` | uv project config: direct dependencies (`[project]`), the `dev`/`build` dependency-groups, plus ruff/mypy/pytest settings. | +| `uv.lock` | The fully-resolved, pinned dependency graph. Source of truth for exact versions; `uv sync` installs it. Commit it. | +| `requirements.txt` | **Generated** pip fallback, exported from `uv.lock` for environments without uv. Don't hand-edit. | +| `docs/ARCHITECTURE.md` | Runtime design from BLE capture through FIT, summary, and upload. Keep it current when behavior changes. | +| `docs/AGENTS.md` | Repository instructions and invariants for AI assistants. | +| `docs/STYLE_GUIDE.md` | Coding, typing, testing, and documentation conventions. | +| `docs/DEVELOPMENT.md` | Local environment, source workflow, dependencies, and debugging. | +| `docs/TESTING.md` | Required checks, test isolation, coverage map, and smoke testing. | +| `docs/DEPLOYMENT.md` | Local package builds, release publication, verification, and rollback. | +| `docs/ROADMAP.md` | Prioritized open audit findings and planned work. | + +If you only remember one rule: **`main` is always releasable.** Nothing gets +built or shipped from a branch — only tagged commits on `main` produce a +release. + +--- + +## 2. Making a change (feature branches) + +First-time setup (once per clone): the project uses **uv** for dependency +management and targets **Python 3.14** — the same version CI and the release +build use. One command creates the `.venv`, fetches Python 3.14 if you don't +have it, and installs exactly what `uv.lock` pins (runtime deps + the `dev` +tooling group): + +```bash +uv sync +``` + +Then run commands with `uv run ` (no manual activation needed), or +`source .venv/bin/activate` once and run them directly. If `uv.lock` doesn't +exist yet, `uv sync` (or `uv lock`) generates it — commit the result so everyone +resolves to the same versions. A `requirements.txt` is exported from the lock as +a pip fallback for environments without uv; regenerate it with +`uv export --format requirements-txt --no-hashes --all-groups -o requirements.txt`. + +1. Branch off `main`: + + ```bash + git checkout main + git pull + git checkout -b feature/short-description + ``` + + Branch prefixes are not enforced by tooling, but use something readable: + `feature/...`, `fix/...`, `chore/...`. + +2. Make your change. Run the relevant tests **and** the lint/type checks + locally before pushing — all three are merge requirements for `main` + (see section 3 for `ruff`/`mypy` setup): + + ```bash + uv run ruff check . # lint + import order — must be clean + uv run mypy . --strict # static type checking — must be clean + uv run pytest tests/test_pipeline.py + uv run pytest tests/test_read_csv.py + # ...or the complete suite: + uv run pytest + ``` + + The suite runs in one pytest process using the locked runtime dependencies. + +3. Push the branch and open a pull request into `main`. GitHub will + pre-fill the PR description from `.github/PULL_REQUEST_TEMPLATE.md` — + fill in the summary, changes, and testing sections, and check off the + checklist items honestly (don't tick "tests pass" without running them). + +**Opening a PR does not build or release anything.** `build-release.yml` +only publishes a release when a bare semantic-version tag is pushed — see +section 5. Manual workflow runs build test artifacts but do not publish a +GitHub Release. + +--- + +## 3. Code and naming conventions + +### Linting and type checking (required) + +Two checks must pass with **zero errors** on every pull request into `main`, +alongside the test suite: **`ruff`** (lint + import ordering) and **`mypy`** +(static type checking, in **`strict`** mode). Their rules live in +`pyproject.toml` (`[tool.ruff]` / `[tool.mypy]`); `docs/STYLE_GUIDE.md` has the fuller +rationale. Strict means new code needs complete annotations and no bare generics +(`dict[str, Any]`, not `dict`); the unstubbed GUI/BLE/FIT libraries are kept +non-fatal via `ignore_missing_imports`. **CI enforces both** — +the `lint` job in `ci.yml` runs `uv run ruff check .` and `uv run mypy . --strict` on every PR, and a +failure blocks the merge — so run them locally first to avoid a red build. + +Both live in the `dev` dependency-group in `pyproject.toml` (along with the +`types-*` stub packages mypy reads and `pytest`), so the first-time `uv sync` +from section 2 already installs them at the exact versions CI uses — no separate +install step. They're build/dev-time only: PyInstaller doesn't bundle them into +the shipped app. Run both from the repo root before pushing: + +```bash +uv run ruff check . # lint + import order +uv run mypy . --strict # static type checking (whole repo) +``` + +`mypy` runs strict without a `--strict` flag: `strict = true` lives in +`[tool.mypy]` in `pyproject.toml`, so `uv run mypy . --strict` (and your editor, and CI) +all pick it up from config. It checks `tests/` too; the `tests.*` override +relaxes only annotation-presence rules and automatically covers new modules. + +A PR that reports any `ruff` or `mypy` error is not ready to merge — fix the +findings rather than suppressing them wholesale. Reach for `# noqa: ` or +`# type: ignore[]` only for the deliberate exceptions already documented +in `docs/STYLE_GUIDE.md` (e.g. intentional `Any` use and docstring-presence +judgment calls), +and always pin the specific rule/error code: `warn_unused_ignores` is enabled, +so a suppression that stops being necessary becomes an error in its own right. + +Two build-output details worth knowing so a clean checkout stays clean: + +- The PyInstaller output dirs (`build-mac/`, `dist-mac/`, `build-win/`, + `dist-win/`) and `.venv` are excluded from **both** tools — Ruff via + `extend-exclude` and mypy via its `exclude` regex (which matches `/` and `\` + so it holds on Windows too). Don't type-check or lint a packaged app. +- `tests/*.py` are exempt from the docstring/annotation rules (Ruff + `per-file-ignores`), and a matching mypy `tests.*` override relaxes the + same *annotation-presence* strictness there. They are **not** otherwise exempt + from mypy — real type errors in tests (bad generics, attribute typos, + non-overlapping comparisons) still fail the check. + +### Naming conventions + +The naming rules the codebase follows: + +| Kind | Convention | Examples | +|---|---|---| +| Modules / packages / files | `snake_case`, lowercase | `workout_worker.py`, `export_fit.py`, `app/`, `gui/` | +| Classes | `CapWords`; initialisms title-cased, not shouted | `WorkoutSession`, `CsvReader`, `FitExporter`, `LiveSegmenter` | +| Functions, methods, variables, parameters | `snake_case` | `compute_summary`, `detect_segments`, `max_gap_seconds` | +| Constants / module-level config | `UPPER_SNAKE_CASE` | `DEFAULT_MAX_GAP_SECONDS`, `MPS_TO_KMH` | +| Internal / non-public helpers | leading underscore | `_axis_bounds`, `_lap_summary`, `_MultiChart` | + +Two conventions specific to this codebase: + +- **Suffix physical quantities with their unit**, matching the CSV column + schema in `app/field_mapping.py`: `_m` (metres), `_s` (seconds), `_ms` + (milliseconds), `_mps` (metres/second) — e.g. `distance_m`, `duration_s`, + `start_ms`, `avg_speed_mps`. A bare quantity name is a smell; name the unit. +- **Worker entry modules are named `_worker.py`** with a matching + `run__worker` entry function — `workout_worker.py` / + `run_workout_worker`, `export_worker.py` / `run_export_worker`. + +Also preserve the deliberate `Decimal`/`float` split (FIT-facing code uses +`Decimal`/`int`, GUI-facing code uses `float`/`int`; see `app/stats.py`), +keep imports in three alphabetized groups (stdlib, third-party, local — with +`app` and `gui` as first-party), and hold to the 100-column line length. + +--- + +## 4. Pull requests and code review + +Every PR into `main` automatically: + +- Triggers **CI** (`ci.yml`), which runs two kinds of job, all of which must + pass: + - **`lint`** — `ruff check .` and `mypy . --strict` on Linux (see section 3). + - **`test`** — the full `tests/test_*.py` suite on macOS and Windows, the two + platforms the app ships on. +- Requires **review from the code owner(s)** listed in `.github/CODEOWNERS` + (currently `@konverga` for the whole repo) before it can be merged, once + branch protection is enabled (see section 7 — this is a one-time repo + setting, not something enforced by a file). + +Guidelines for the PR itself: + +- **`ruff check .` and `mypy . --strict` must both pass with zero errors** before the PR + is merged into `main` (see section 3). CI's `lint` job runs them on every PR, + so run them locally first — a clean lint/type-check is a merge requirement, + exactly like green tests. +- Keep PRs scoped to one feature/fix — easier to review, easier to revert. +- Link the related issue with `Closes #123` if one exists. +- If your change affects behavior end users would notice, update `README.md` + in the same PR. If it changes how the app works internally (BLE + handshake/polling, packet parsing, FIT export, config handling), update + `docs/ARCHITECTURE.md` too, so the internals doc doesn't drift. +- If your change touches Bluetooth parsing, FIT export, or config handling, + add or update a test in `tests/` — there's no formal coverage requirement, + but these are the modules most likely to break silently. + +Once CI is green and the PR is approved, merge it (squash merge is +recommended to keep `main`'s history one commit per change). Deleting the +branch after merge is safe — nothing references it. + +--- + +## 5. Versioning and releasing a new version + +### Version numbers + +KayakFit uses [semantic versioning](https://semver.org/): every release is +`MAJOR.MINOR.PATCH`. The project **starts at `0.1.0`** and stays in the `0.x` +range during initial development. The current number is `__version__` in +`app/__init__.py`, mirrored by `version` in `pyproject.toml` — **bump both +together** (they're expected to match). The git tag you push at release time +must match them (see below), and the build bakes that tag into the macOS bundle +version and the Windows file/product version. + +Bump the number by the *largest* kind of change since the last release: + +| Change | While in `0.x` (now) | After `1.0.0` | +|---|---|---| +| Bug fix / internal-only change, no behaviour change users rely on | **PATCH** (`0.1.0 → 0.1.1`) | PATCH (`1.4.2 → 1.4.3`) | +| New feature, **or** a breaking change (incompatible config/CSV/FIT format, removed setting) | **MINOR** (`0.1.0 → 0.2.0`) | MINOR for features (`1.4.0 → 1.5.0`), MAJOR for breaking (`1.4.0 → 2.0.0`) | +| First stable, API/format-committed release | — | promote to `1.0.0` | + +While the project is in `0.x`, the public surface (config file, CSV/FIT +layout, presets) is explicitly **not** guaranteed stable — per the semver spec, +`0.y.z` is for initial development and anything may change. A breaking change +therefore bumps MINOR, not MAJOR, until the project is deliberately promoted to +`1.0.0`. Update `__version__` (and the matching `version` in `pyproject.toml`) +in the same PR as the change that warrants the bump, so `main` always reflects +the version it would release as. + +### Cutting the release + +Releases are **tag-triggered**, not merge-triggered. Merging PRs into `main` +never publishes a build. To ship: + +1. Make sure `main` is in the state you want to release (all the PRs you + want are merged, CI is green) and `__version__` in `app/__init__.py` is the + number you intend to tag. + +2. Confirm the version number using the table above — e.g. `0.1.1` for a bug + fix, `0.2.0` for a new feature or a breaking format change while in `0.x`. + +3. Tag the commit on `main` and push the tag: + + ```bash + git checkout main + git pull + git tag 0.2.0 + git push origin 0.2.0 + ``` + + Pushing the tag alone is enough — `build-release.yml` will build both + platforms and create the GitHub Release automatically. Publishing a release + through the GitHub UI does not trigger this workflow; edit the generated + release notes after the tagged build finishes if they need customization. + +4. Wait for the **Build & Release** workflow to finish (Actions tab). When + it completes, the Release page will have two assets attached: + `KayakFit-macOS-arm64.zip` (Apple Silicon) and `KayakFit-Windows.zip`. Both + are built with the version number baked in (macOS bundle version and Windows + file/product version both read from the tag). + +5. Sanity-check the release notes and assets, then announce/share as needed. + +**Testing a build without releasing:** run the same workflow manually from +Actions → Build & Release → Run workflow. This builds both platforms and +uploads them as workflow artifacts (visible on the run's summary page) but +does **not** create or attach anything to a Release. + +Currently there is no code signing configured, so macOS will show a +Gatekeeper warning ("unidentified developer") and Windows will show a +SmartScreen warning on first run of a downloaded build. This is expected +until signing certificates are set up. + +--- + +## 6. Reporting bugs and requesting features + +- Bugs: open an issue using the **Bug Report** template — include OS, + Python version (if running from source), and ergometer/firmware if + relevant. +- Feature ideas: use the **Feature Request** template. +- General questions or open-ended discussion: use + [Discussions](https://github.com/konverga/KayakFit/discussions) instead + of an issue — blank issues are disabled to keep the issue tracker focused + on actionable work. + +--- + +## 7. One-time repo setup (for admins) + +The pieces above assume `main` is protected. If this hasn't been configured +yet, an admin should go to **Settings → Branches → Add branch protection +rule** for `main` and enable: + +- Require a pull request before merging (blocks direct pushes to `main`). +- Require status checks to pass before merging — select all three checks that + `ci.yml` produces: `lint`, `test (macos-14)`, and `test (windows-latest)`. + They only appear as options after the workflow has run at least once, so open + one throwaway PR first. (The `lint` check is what actually makes the `ruff` / + `mypy` requirement in sections 3-4 blocking rather than advisory.) +- Require review from Code Owners. +- Do not allow bypassing the above settings. +- Disable force pushes and branch deletion for `main`. + +This is what actually makes CODEOWNERS and CI enforce anything — without it, +they run/exist but nobody is required to act on them. + +--- + +## Quick reference + +``` +feature branch --PR--> main --tag push (vX.Y.Z)--> GitHub Release + | | + ci.yml build-release.yml + (lint: ruff + mypy on Linux; (builds mac + windows, attaches + tests on mac + windows; to the release) + every PR/push) +``` diff --git a/KayakFit.spec b/KayakFit.spec new file mode 100644 index 0000000..3720236 --- /dev/null +++ b/KayakFit.spec @@ -0,0 +1,249 @@ +# -*- mode: python ; coding: utf-8 -*- +# +# Cross-platform build spec. Builds a macOS .app bundle on macOS and a Windows +# onedir distribution on Windows (PyInstaller is run on each target OS). +from PyInstaller.utils.hooks import collect_all +import os +import sys + +IS_MAC = sys.platform == "darwin" +IS_WINDOWS = sys.platform.startswith("win") + +# Per-platform application icon (macOS uses .icns, Windows uses .ico). +APP_ICON = "assets/icon_kayakfit.icns" if IS_MAC else "assets/icon_kayakfit.ico" + +# App version: the release workflow sets KAYAKFIT_VERSION from the git tag +# (e.g. "1.2.3" from tag "v1.2.3") so the built .app/.exe report the same +# version as the GitHub Release. Local/manual builds fall back to 0.1.0. +APP_VERSION = os.environ.get("KAYAKFIT_VERSION", "0.1.0").lstrip("vV") + + +def _version_tuple(version): + """Parse 'X.Y.Z' into a 4-int tuple for Windows FixedFileInfo.""" + parts = [] + for p in version.split("."): + try: + parts.append(int(p)) + except ValueError: + parts.append(0) + parts += [0] * (4 - len(parts)) + return tuple(parts[:4]) + +# Add local packages as data files +datas = [ + ('assets', 'assets'), + ('presets', 'presets'), # built-in training program JSON files +] +binaries = [] +hiddenimports = [ + 'workout_worker', + 'export_worker', + # Third-party packages + 'yaml', # PyYAML for config management + 'requests', # HTTP library for API calls + # Tkinter modules + 'tkinter', + 'tkinter.ttk', # Tkinter themed widgets + 'tkinter.messagebox', # Tkinter message boxes + 'tkinter.filedialog', # Tkinter file dialogs + 'tkinter.scrolledtext', # Tkinter scrolled text widget + 'customtkinter', # Modern themed widgets used by the GUI + 'darkdetect', # Appearance detection dependency of customtkinter + # Secure credential storage (optional; falls back to file if missing). + 'keyring', + 'keyring.backends.macOS', # macOS Keychain backend + 'keyring.backends.Windows', # Windows Credential Locker backend + 'keyring.backends.SecretService', # Linux backend (best-effort) + # Standard library http modules + 'http.server', + 'urllib.parse', + # platform.mac_ver() imports plistlib lazily; darkdetect needs it to read + # the macOS version at startup. Make sure it is always bundled. + 'plistlib', + # Bleak (Bluetooth) modules + 'bleak', + 'bleak.backends.characteristic', + 'bleak.exc', + # Explicitly list all modules + 'app.ble_device', + 'app.export_fit', + 'app.field_mapping', + 'app.heart_rate_monitor_bluetooth', + 'app.kayakfirst_ergometer_bluetooth', + 'app.keep_awake', + 'app.logger', + 'app.power', + 'app.program', + 'app.program_runner', + 'app.read_csv', + 'app.recovery', + 'app.secret_store', + 'app.segmentation', + 'app.sound', + 'app.stats', + 'app.strava_api', + 'app.strava_auth', + 'app.strava_uploader', + 'app.summary', + 'app.table', + 'app.workout_session', + 'app.write_csv', + 'gui.config_manager', + 'gui.config_window', + 'gui.dashboard_constants', + 'gui.device_scanner', + 'gui.device_status', + 'gui.export_upload', + 'gui.history_window', + 'gui.main_gui', + 'gui.metrics_format', + 'gui.program_panel', + 'gui.recording_lifecycle', + 'gui.setup_wizard', + 'gui.summary_window', + 'gui.window_utils', + 'gui.worker_manager', +] + +tmp_ret = collect_all('bleak') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('fit_tool') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +# customtkinter ships theme/asset JSON files that must be bundled. +tmp_ret = collect_all('customtkinter') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +# keyring discovers backends via entry points; collect them so the secure +# credential path works in frozen builds. Optional — app falls back if absent. +try: + tmp_ret = collect_all('keyring') + datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +except Exception: + pass + + +a = Analysis( + ['kayakfit_gui.py', 'workout_worker.py', 'export_worker.py'], + pathex=['.'], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + # Hardens platform.mac_ver() so darkdetect/customtkinter can't crash on + # startup when the OS version string is unreadable (e.g. inside VMs). + runtime_hooks=['hooks/pyi_rth_mac_ver.py'], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +if IS_WINDOWS: + # Embed file/product version info so Explorer's Properties > Details tab + # and installers report the same version as the GitHub Release. + from PyInstaller.utils.win32.versioninfo import ( + VSVersionInfo, FixedFileInfo, StringFileInfo, StringTable, + StringStruct, VarFileInfo, VarStruct, + ) + + _ver = _version_tuple(APP_VERSION) + version_info = VSVersionInfo( + ffi=FixedFileInfo( + filevers=_ver, + prodvers=_ver, + mask=0x3F, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable('040904B0', [ + StringStruct('CompanyName', 'Konverga'), + StringStruct('FileDescription', 'KayakFit'), + StringStruct('FileVersion', APP_VERSION), + StringStruct('InternalName', 'KayakFit'), + StringStruct('OriginalFilename', 'KayakFit.exe'), + StringStruct('ProductName', 'KayakFit'), + StringStruct('ProductVersion', APP_VERSION), + ]), + ]), + VarFileInfo([VarStruct('Translation', [1033, 1200])]), + ], + ) + + # Single-file executable: binaries/datas are embedded directly in the + # EXE (exclude_binaries=False) and unpacked to a temp dir at runtime, so + # the final deliverable is one standalone dist-win\KayakFit.exe file. + exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='KayakFit', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + # Windowed app: no console window behind the GUI. Diagnostics go to + # the rotating log at ~/KayakFit/logs/kayakfit.log (the logger skips + # the console sink when stdout is absent in a windowed build). + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=[APP_ICON], + version=version_info, + ) +else: + exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='KayakFit', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=[APP_ICON], + ) + coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='KayakFit', + ) + +# macOS .app bundle (with Bluetooth usage strings). On Windows the EXE above +# (dist-win\KayakFit.exe) is the final single-file deliverable. +if IS_MAC: + app = BUNDLE( + coll, + name='KayakFit.app', + icon='assets/icon_kayakfit.icns', + bundle_identifier='com.kayakfit.app', + info_plist={ + 'CFBundleShortVersionString': APP_VERSION, + 'CFBundleVersion': APP_VERSION, + 'NSHighResolutionCapable': True, + 'NSBluetoothAlwaysUsageDescription': 'KayakFit needs Bluetooth access to connect to your KayakFirst ergometer and heart rate monitor for workout tracking.', + 'NSBluetoothPeripheralUsageDescription': 'KayakFit needs Bluetooth access to connect to your KayakFirst ergometer and heart rate monitor for workout tracking.', + }, + ) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a22e6e --- /dev/null +++ b/README.md @@ -0,0 +1,395 @@ +# KayakFit + +KayakFit is a desktop app for logging **KayakFirst Bull** ergometer workouts. It +connects over Bluetooth to your ergometer and, optionally, a standard BLE +heart-rate monitor. It shows a live metric dashboard while you paddle and saves +each session as a `.csv` and a `.fit` file, with one-click upload to Strava. + +Runs on **macOS** and **Windows**. + +--- + +## Features + +- **Live dashboard** — time, distance, instantaneous speed, pace, stroke rate, + pull force and estimated power, plus heart rate, updated once per second. +- **Heart‑rate zones** — colored zones with bpm ranges, derived from your max HR + or set manually. +- **Auto‑pause & laps** — the live pause state and free-lap boundary require + five continuous seconds without movement; each meaningful effort becomes a + lap in the FIT file. When you run a **structured program**, the FIT laps + follow the program steps instead, so each interval (warm‑up, work, rest…) + shows up as its own lap on Strava. +- **Structured training programs** — run interval sessions (e.g. *4 × 500 m / + 1:00 rest*) with a live step panel, countdowns, and audible cues on each phase + change. +- **Workout summary** — per‑workout detail screen with stacked charts for + speed, power, heart rate, cadence and pull force on one shared time axis + (pick the metrics you want, hover for exact values), time in HR zones, and + per‑lap splits. +- **Estimated power** — the ergometer has no power sensor; KayakFit applies its + power model to each instantaneous force/cadence point. +- **Strava upload** — convert to FIT and upload automatically or on demand. FIT + records map active ergometer distance onto a fixed Hazewinkel course so + Strava can infer pauses from stationary positions in its overview. +- **Robustness** — crash recovery for interrupted sessions, inactivity + auto‑stop, the screen is kept awake during a workout, the HRM battery level + is shown so a dying strap isn’t a surprise, and the Stop button stays + responsive even while the ergometer connection is retrying. +- **First‑run setup wizard** to get your devices connected quickly. + +--- + +## Requirements + +**Hardware** +- A KayakFirst Bull ergometer (Bluetooth). +- *(Optional)* Any standard BLE heart‑rate monitor. +- A Mac or Windows PC with Bluetooth. + +**Software (to run from source or build)** +- [uv](https://docs.astral.sh/uv/) — the dependency manager and runner. +- **Python 3.14**. You don't + need to install Python yourself: `uv` fetches the right version for you. + +You don’t need Python or uv at all if you use a packaged build (see +[Building a standalone app](#building-a-standalone-app)). + +--- + +## Install & run from source + +```bash +# 1. Clone the repo and enter it +cd KayakFit + +# 2. Install dependencies into a managed .venv (creates it, fetches Python 3.14 +# if needed, and installs exactly what uv.lock pins) +uv sync + +# 3. Run the app +uv run python kayakfit_gui.py +``` + +`uv run` executes inside the project environment without activating it; if you +prefer, `source .venv/bin/activate` (macOS/Linux) or `.venv\Scripts\activate` +(Windows) once, then run `python kayakfit_gui.py` directly. + +No uv? A `requirements.txt` is generated from the lock as a pip fallback: +`python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt`. + +On macOS you’ll be asked to grant **Bluetooth** permission the first time. + +--- + +## First‑time setup + +On first launch a short **setup wizard** appears: + +1. Enter the device name printed in large letters on the ergometer, then + **scan** for Bluetooth devices (turn your ergometer on and keep it nearby). +2. **Pick your ergometer** and, optionally, a heart‑rate monitor. +3. Optionally choose to set up **Strava** afterward. + +You can redo any of this later in **Settings**. + +### Connecting Strava (optional) + +1. Create an API application at . +2. Set its **Authorization Callback Domain** to `127.0.0.1`. +3. In KayakFit → **Settings → Strava**, paste your **Client ID** and + **Client Secret** and click **Connect to Strava**. A browser window opens for + you to authorize; KayakFit captures the result automatically. + +Your complete Strava authorization is stored in the operating-system keychain, +not in a plain file. It survives deletion of `~/KayakFit`; use **Disconnect** in +Settings to remove it from the keychain. + +--- + +## Recording a workout + +1. Set **Boat (kg)** and **Person (kg)** in the setup bar for the ergometer's + workout calculations. +2. *(Optional)* Pick a **Program** from the dropdown, or leave it on + *Free workout*. +3. Click **Start workout** (or press the **spacebar**). +4. Paddle. The dashboard updates live; the heart‑rate tile is tinted by zone. +5. When you’re done, click **Stop & save** — you’ll be asked to **tap again to + confirm** so a stray click can’t end the session. Press the spacebar twice for + the same effect. + +On stop, the workout is saved as CSV + FIT and (if enabled) uploaded to Strava. + +**During the workout you’ll also see:** +- a **⏸ Paused** badge and dimmed tiles after five seconds of continuous inactivity, +- a **Lap N** counter, +- the **program panel** (if running a program) with the current step, a + countdown to the next phase, the next step, and two progress bars — one for + the **current step** and a thin one for the **whole workout**, +- warning **banners** for things like a lost connection or a low HRM battery. + +If you forget to stop, the workout **auto‑stops and saves after 30 minutes** of +no movement. + +### Workouts + +**Workouts** is the one place for past sessions. It lists them newest first; +use the search box to filter the loaded list by filename or workout type, click +one to select it (the most recent is pre‑selected, double‑click opens its +summary), and use the action bar at the bottom: **Summary** and **Save FIT** +(CSV files), and **Upload** (CSV or FIT, needs a connected Strava account). +**Open folder** reveals the files in Finder/Explorer, and **Choose file…** adds +a workout stored somewhere else to the list so the same buttons work on it. + +**Summary** opens a detail screen for a workout: headline stats (active distance, +active/elapsed/pause time, average/max sensor speed, heart rate, power, stroke rate), stacked +per‑metric raw-point charts — sensor speed, power, heart rate, cadence and pull force, each +with its own value axis on one shared time axis — time in each heart‑rate +zone, and a per‑lap splits table. Checkboxes above the chart choose which +metrics are shown, and hovering shows a crosshair with the exact values at +that moment. Heart‑rate elements appear whenever the workout has HR data. +The laps match the FIT export, so Strava receives the same split structure +(although Strava controls how it presents imported laps). +The same screen is available from the **View summary** button on the +"Workout saved" card right after you stop a workout. + +For the exact rules and worked examples, see +[`docs/DATA_TRANSFORMATIONS.md`](docs/DATA_TRANSFORMATIONS.md). It explains +active versus elapsed time, pause handling, speed and power calculations, +free/planned laps, FIT fields, the synthetic GPS track, and what Strava may +recalculate after upload. + +--- + +## Training programs + +A program is a sequence of steps (warm‑up, work, rest, cool‑down). All plans +live as JSON files in **`~/KayakFit/programs/`** and appear in the Program +dropdown (refreshed when the window regains focus). On first run the bundled +example plans are copied there, so you can edit, rename, or delete them like +any other plan — deleted examples stay deleted. The 📂 button next to the +dropdown opens the folder. + +To add your own, drop a `.json` file like this into `~/KayakFit/programs/`: + +```json +{ + "name": "5 × 400 m in Zone 4 / 90 s rest", + "steps": [ + {"type": "warmup", "duration": {"kind": "time", "seconds": 300}}, + {"repeat": 5, "steps": [ + {"type": "work", + "duration": {"kind": "distance", "meters": 400}, + "target": {"metric": "hr_zone", "low": 4, "high": 4}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 90}} + ]}, + {"type": "cooldown", "duration": {"kind": "time", "seconds": 300}} + ] +} +``` + +### Step fields + +| Field | Values | +|---|---| +| `type` | `warmup` · `work` · `effort` · `rest` · `cooldown` | +| `duration.kind` | `time` (with `seconds`), `distance` (with `meters`), or `open` (advance manually) — the step advances when that time/distance is reached, or immediately when you press **Next step** for an open step | +| `label` | *(optional)* overrides the auto‑generated step name (e.g. `"2 km TT"`) | +| `target` | *(optional)* the intended intensity for the step — see below | +| `repeat` | wraps a list of `steps` and repeats them a positive whole-number of times | + +### Targets (optional) + +A step may carry a `target` of the form `{"metric", "low", "high"}` describing +the effort you’re aiming for. `low`/`high` are inclusive bounds — use the **same +value for both** to target a single zone. + +| `metric` | What `low` / `high` mean | Example | +|---|---|---| +| `hr_zone` | heart‑rate zone **1–5** | `{"metric": "hr_zone", "low": 3, "high": 3}` → hold Zone 3 | +| `pace_200` / `pace_500` / `pace_1000` | **seconds** per that split | `{"metric": "pace_500", "low": 130, "high": 145}` → 2:10–2:25 per 500 m | +| `power` | **watts** | `{"metric": "power", "low": 180, "high": 220}` | +| `spm` | **strokes per minute** | `{"metric": "spm", "low": 58, "high": 64}` | + +Heart‑rate zones follow your **Settings → Heart‑rate zones** (auto from Max HR, +or your manual bpm bounds), so `hr_zone` targets adapt to your configuration. + +While a step with a target is running, the program panel shows a live badge — +**On target** (green), **Too hard ↑** (red) or **Too easy ↓** (amber) — comparing +your current effort to the target range, and a sound plays when you drift out of +range (if sound cues are enabled). Steps without a `target` (like a plain 500 m +interval) simply omit the badge. + +To avoid flicker from normal second‑to‑second fluctuation, the badge only +changes once a status has held for a short **grace period** (default **3 s**, set +via **Settings → Logging & processing → Target grace**) — so a brief blip across +the boundary won’t flip the color or trigger the alert. + +The program panel has a **Next step ▸** button (also the **→** arrow key) that +advances to the next step — required for `open` steps, and handy to skip ahead on +timed/distance steps. When a program finishes, the workout stops and saves +automatically. + +When you record with a program, KayakFit remembers each step's boundaries and +uses them to define the **laps** in the exported FIT file — so a *4 × 500 m* +session appears on Strava as clean interval laps rather than laps guessed from +auto‑pauses. (Free workouts still fall back to auto‑pause laps.) + +--- + +## Settings reference + +| Setting | What it does | +|---|---| +| **Devices** | Scan for, select and **test** your ergometer / HRM. A **Use heart‑rate monitor** switch disables the HRM entirely — no connection attempt and no "continue without HRM?" prompt if you don't own a strap. | +| **Weight defaults** | Default boat and person weight, and **Max HR**. | +| **Heart‑rate zones** | `auto` (derive zones from Max HR) or `manual` (set the five bpm bounds). | +| **Display layout** | Which metrics the ergometer’s own screen shows. | +| **Logging & processing** | Log interval/level, auto-upload, **sound cues** on program phase changes, and the **target grace** (seconds before the target badge changes). | +| **Strava** | Connect/disconnect your Strava account. | + +--- + +## Where your data is stored + +Everything lives under a **`KayakFit`** folder in your home directory +(`~/KayakFit` on macOS, `C:\Users\\KayakFit` on Windows): + +``` +KayakFit/ +├── workouts//workout_YYYYMMDD_HHMMSS/ +│ ├── workout.csv # raw sensor samples +│ ├── metadata.json # recorded processing settings +│ ├── steps.json # planned workouts only +│ └── activity.fit # created on save/upload +├── programs/*.json # training plans (examples + your own) +├── config.yml # settings +└── logs/kayakfit.log # rotating diagnostic log +``` + +The complete Strava authorization is kept in the OS keychain, not in +`config.yml`. It survives deletion of this folder; use **Disconnect** in +Settings to remove it. + +One tuning value worth knowing about: `pull_length_m` in `config.yml` is the +effective handle pull length used by the power model (default `0.600`, applied +to instantaneous force and cadence). If your machine or firmware reads differently, +it can be refitted — the procedure is described in +[`docs/POWER_MODEL.md`](docs/POWER_MODEL.md). + +--- + +## Building a standalone app + +Builds use **PyInstaller** with the cross‑platform `KayakFit.spec`. Run the build +**on the OS you’re targeting** (build on a Mac for the Mac app, on Windows for the +Windows app). + +PyInstaller lives in the `build` dependency-group, so install it first with +`uv sync --group build`, then run the build with `uv run`. + +**macOS** + +```bash +uv sync --group build +./pyinstaller_build.sh # or: uv run pyinstaller KayakFit.spec --distpath dist-mac --workpath build-mac +# Result: dist-mac/KayakFit.app +``` + +**Windows** + +```bat +uv sync --group build +pyinstaller_build.bat :: or: uv run pyinstaller KayakFit.spec --distpath dist-win --workpath build-win +:: Result: dist-win\KayakFit.exe (single-file executable) +``` + +Each platform builds into its own `dist-mac`/`dist-win` (and `build-mac`/`build-win`) +folder. This matters if the project directory is shared between a Mac and a Windows +machine (e.g. a mapped network drive): a macOS `.app` bundle contains Unix symlinks +that Windows can't delete, so a shared `dist/`/`build/` folder can break the other +platform's clean step. + +The spec bundles the app icon, the `presets/` training plans, and the required +data files for bleak, customtkinter, fit‑tool and keyring. (Strava is reached +through a small `requests`-based client, so no third-party Strava library is +bundled.) + +--- + +## Development + +The tests are plain-`assert` `pytest` tests: + +```bash +uv run pytest +``` + +Or run a single suite while working on it: + +```bash +uv run pytest tests/test_segmentation.py # auto‑pause / lap detection +``` + +The suites cover: `test_pipeline` (CSV → FIT conversion + config validation), +`test_program` (training‑program engine), `test_program_laps` (program‑step → +lap reconciliation), `test_segmentation` (auto‑pause / lap detection), +`test_fit_events` (FIT start/stop event timeline), `test_read_csv` (CSV reader), +`test_hr_zones` (heart‑rate zones), `test_recovery` (crash‑recovery marker), +`test_ble_parsing` (ergometer packet framing), `test_summary` (workout summary, +incl. FIT/summary endpoint agreement), `test_stats` (shared avg/max metric +aggregation) and `test_strava` (Strava REST client). + +**Lint and type checks.** Contributions to `main` must also pass `ruff` +(lint + import order) and `mypy` (static types) with zero errors — CI enforces +both, so run them before opening a pull request (both are in the `dev` group +installed by `uv sync`): + +```bash +uv run ruff check . +uv run mypy . --strict +``` + +See `CONTRIBUTING.md` for the full workflow (branching, PRs, releases). + +Repository documentation is split by purpose: + +- [`docs/AGENTS.md`](docs/AGENTS.md) — instructions for AI assistants. +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — runtime and data flow. +- [`docs/DATA_TRANSFORMATIONS.md`](docs/DATA_TRANSFORMATIONS.md) — sensor-to-summary, + FIT, and Strava calculations with worked examples. +- [`docs/FEATURES.md`](docs/FEATURES.md) — implemented user-facing features. +- [`docs/ROADMAP.md`](docs/ROADMAP.md) — prioritized open findings. +- [`docs/DECISIONS.md`](docs/DECISIONS.md) — architecture decisions and invariants. +- [`docs/STYLE_GUIDE.md`](docs/STYLE_GUIDE.md) — coding and test conventions. + +**Project layout** + +``` +app/ device I/O (BLE), CSV/FIT, Strava, programs, session logic +gui/ the customtkinter interface +tests/ unit/integration tests +presets/ example training programs (JSON), copied to ~/KayakFit/programs on first run +assets/ icons +docs/ assistant instructions, architecture, features, roadmap, decisions, and style +kayakfit_gui.py application entry point +KayakFit.spec PyInstaller build spec +``` + +--- + +## Troubleshooting + +- **No devices found when scanning** — check that the ergometer device name is + entered exactly as printed on the machine, make sure Bluetooth is on, the + ergometer is powered and nearby, and (macOS) that KayakFit has Bluetooth + permission. Use **Settings → Test connection** to verify a device is reachable. +- **Workout won’t start / HRM not found** — if a configured strap can’t be + reached you’ll be asked whether to continue without heart rate. If you don’t + use a strap at all, turn off **Use heart‑rate monitor** in Settings → + Devices and the question goes away for good. +- **Strava upload fails** — check that your API app’s callback domain is + `127.0.0.1`, and that KayakFit shows *Strava · connected* in the status bar. +- **A workout was interrupted** — on the next launch KayakFit offers to recover + and export the unfinished session; the CSV is always preserved in Workouts. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f0b7cd5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security policy + +## Supported versions + +KayakFit is currently in initial development. Security fixes are provided for +the latest release only. + +## Reporting a vulnerability + +Please do not open a public issue for a suspected vulnerability. Use GitHub's +private vulnerability reporting feature instead: + +1. Open the repository's **Security** tab. +2. Select **Report a vulnerability**. +3. Include the affected version, reproduction steps, potential impact, and any + suggested mitigation. + +Please avoid accessing data that is not your own, disrupting services, or +publishing the issue before a fix is available. You can expect an initial +acknowledgement within seven days. Confirmed reports will receive periodic +status updates until they are resolved or otherwise closed. + +If private vulnerability reporting is not available, contact the repository +owner privately through their GitHub profile and ask for a secure reporting +channel. Do not include vulnerability details in that initial message. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..297af8c --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,13 @@ +"""KayakFit - Workout Data Logger. + +Modular package for logging kayak workout data from Bluetooth devices. + +Import submodules directly (e.g. ``from app.read_csv import CsvReader``). This +module deliberately has no eager imports, keeping pure processing modules +independent from BLE, GUI, and FIT dependencies. + +The project follows semantic versioning starting from 0.1.0 (see the +"Versioning" section in ``CONTRIBUTING.md``). +""" + +__version__ = "0.1.0" diff --git a/app/active_distance.py b/app/active_distance.py new file mode 100644 index 0000000..8f76e2f --- /dev/null +++ b/app/active_distance.py @@ -0,0 +1,87 @@ +"""Shared active-time and active-distance accumulation. + +The ergometer publishes cumulative distance for every packet, including idle +packets. This accumulator attributes distance only to active sample windows and +is used by both batch finalization and the live dashboard preview. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +from typing import Any + + +def _finite_nonnegative(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if isfinite(number) and number >= 0 else None + + +@dataclass(frozen=True) +class ActiveDistanceUpdate: + """Result of closing one measurable active-distance span.""" + + distance_m: float + duration_s: float + cumulative_distance_m: float + +class ActiveDistanceAccumulator: + """Accumulate active distance/time from cumulative-distance packets.""" + + def __init__(self) -> None: + self._previous_distance_m = 0.0 + self._pending_duration_s = 0.0 + self.active_distance_m = 0.0 + self.active_time_s = 0.0 + + def update( + self, + *, + distance_m: Any, + duration_s: Any, + active: Any, + ) -> ActiveDistanceUpdate | None: + """Fold one packet into the active domain. + + A missing active odometer reading defers its represented duration to the + next valid reading. An idle packet advances the raw-distance baseline + without crediting its distance or duration to the activity. + """ + try: + is_active = float(active) > 0 + except (TypeError, ValueError): + is_active = False + represented_s = _finite_nonnegative(duration_s) or 0.0 + cumulative_m = _finite_nonnegative(distance_m) + + # A retained observation with no represented interval cannot own time + # or distance. Treat it like an idle baseline update so live and batch + # processing agree for initial/repeated elapsed-counter values. + if represented_s <= 0: + is_active = False + + if not is_active: + self._pending_duration_s = 0.0 + if cumulative_m is not None: + self._previous_distance_m = cumulative_m + return None + + self.active_time_s += represented_s + self._pending_duration_s += represented_s + + if cumulative_m is None: + return None + + delta_m = max(cumulative_m - self._previous_distance_m, 0.0) + span_duration_s = self._pending_duration_s + self._pending_duration_s = 0.0 + self._previous_distance_m = cumulative_m + self.active_distance_m += delta_m + return ActiveDistanceUpdate( + distance_m=delta_m, + duration_s=span_duration_s, + cumulative_distance_m=self.active_distance_m, + ) diff --git a/app/atomic_json.py b/app/atomic_json.py new file mode 100644 index 0000000..ee3eb6c --- /dev/null +++ b/app/atomic_json.py @@ -0,0 +1,28 @@ +"""Durable atomic JSON persistence for workout control files.""" + +import contextlib +import json +import os +from pathlib import Path +from typing import Any + + +def write_atomic_json(path: Path, payload: Any) -> None: + """Replace ``path`` only after its complete JSON payload is durable.""" + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(path.name + ".tmp") + try: + with open(temp, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp, path) + if os.name != "nt": + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + with contextlib.suppress(OSError): + temp.unlink() diff --git a/app/ble_device.py b/app/ble_device.py new file mode 100644 index 0000000..1bc2d14 --- /dev/null +++ b/app/ble_device.py @@ -0,0 +1,247 @@ +"""Shared BLE device plumbing. + +Base class for the ergometer and heart-rate monitor handlers: retrying +connection with exponential backoff, notification subscription, automatic +mid-session reconnect, status-event emission, and graceful disconnect. + +Subclasses set ``NOTIFY_UUID``, ``device_key`` (status-event identifier) and +``display_name`` (log text), and implement ``_on_notify``. Optional hooks: +``_prepare_link`` runs before every connection attempt, ``_after_connect`` +runs after notifications start (also on reconnect). +""" + +import asyncio +import contextlib +from collections.abc import Callable +from typing import Any, cast + +from bleak import BleakClient +from bleak.backends.device import BLEDevice + +from .events import StatusPayload +from .logger import Logger + + +class BleDevice: + """A BLE peripheral with retrying connect and auto-reconnect.""" + + NOTIFY_UUID: str = "" + device_key: str = "device" # status-event identifier (e.g. "ergometer") + display_name: str = "Device" # human-readable, used in log lines + + def __init__( + self, + address: str, + log_level: str = "INFO", + connect_timeout: float = 15.0, + connect_retries: int = 3, + auto_reconnect: bool = True, + reconnect_delay: float = 5.0, + status_callback: Callable[[StatusPayload], None] | None = None, + ble_device: BLEDevice | None = None, + ) -> None: + """Initialize the device handler. + + Args: + address: Bluetooth device address. + log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). + connect_timeout: Per-attempt BLE connection timeout in seconds. + connect_retries: Number of connection attempts before giving up. + auto_reconnect: Reconnect automatically if the link drops mid-session. + reconnect_delay: Delay between reconnect cycles in seconds. + status_callback: Optional callback receiving connection status events + (dicts with ``event`` and ``device`` keys). + ble_device: Already-discovered device. Supplying this avoids Bleak's + implicit address lookup when opening the connection. + """ + self.address = address + self._ble_device = ble_device + self.log_level = log_level + self.connect_timeout = connect_timeout + self.connect_retries = max(1, connect_retries) + self.auto_reconnect = auto_reconnect + self.reconnect_delay = reconnect_delay + self.status_callback = status_callback + + self.client: BleakClient | None = None + self.callback: Callable[..., Any] | None = None + + self._should_run = False + self._loop: asyncio.AbstractEventLoop | None = None + self._reconnect_task: asyncio.Task[None] | None = None + + self.logger = Logger.get_logger(name=type(self).__module__) + + # ---- hooks --------------------------------------------------------- + def _prepare_link(self) -> None: + """Called before every connection attempt (e.g. reset parse buffers).""" + + async def _after_connect(self) -> None: + """Called after notifications start, on connect and reconnect.""" + + def _on_notify(self, sender: Any, data: bytearray) -> None: + """Handle an incoming notification. Must be implemented by subclasses.""" + raise NotImplementedError + + # ---- status -------------------------------------------------------- + def _emit_status(self, event: str, device: str, **payload: Any) -> None: + """Send a connection status event to the UI if a callback is registered.""" + if self.status_callback: + # Never let UI plumbing break the BLE link. + with contextlib.suppress(Exception): + self.status_callback( + cast(StatusPayload, {"event": event, "device": device, **payload}) + ) + + # ---- connection ---------------------------------------------------- + async def connect(self, callback: Callable[..., Any]) -> None: + """Connect and start receiving data. + + Retries with exponential backoff and, once connected, automatically + reconnects if the link drops mid-session (when ``auto_reconnect`` is + set). + + Args: + callback: Function to call with data updates. + + Raises: + Exception: The last connection error if all attempts fail. + """ + self.callback = callback + self._should_run = True + self._loop = asyncio.get_running_loop() + + last_error: Exception | None = None + for attempt in range(1, self.connect_retries + 1): + if not self._should_run: + return + try: + await self._open_link() + if not self._should_run: + # Stop was requested while the link was opening — don't + # leave a connected device streaming into a dead session. + await self.disconnect() + return + self.logger.info( + msg=f"Connected to {self.display_name} at {self.address}" + ) + await self._after_connect() + return + except Exception as e: + last_error = e + self.logger.warning( + msg=f"{self.display_name} connect attempt " + f"{attempt}/{self.connect_retries} failed: {e}" + ) + if attempt < self.connect_retries and self._should_run: + await asyncio.sleep(delay=min(2**attempt, 10)) + + assert last_error is not None + raise last_error + + async def _open_link(self) -> None: + """Open the BLE link and subscribe to notifications (single attempt).""" + self._prepare_link() + # Close any previous (half-open) link first: a failed start_notify or + # an unnoticed drop can leave the old client connected, and adapters + # commonly refuse a second connection to the same device. Detach it + # from ``self.client`` before disconnecting so its disconnected + # callback is recognized as stale and doesn't trigger a reconnect. + old, self.client = self.client, None + if old is not None: + with contextlib.suppress(Exception): + await asyncio.wait_for(old.disconnect(), timeout=2.0) + client = BleakClient( + address_or_ble_device=( + self._ble_device if self._ble_device is not None else self.address + ), + timeout=self.connect_timeout, + disconnected_callback=self._handle_disconnect, + ) + self.client = client + try: + await client.connect() + await client.start_notify( + char_specifier=self.NOTIFY_UUID, + callback=self._on_notify, + ) + except BaseException: + # Cancellation can arrive while connect() or start_notify() is in + # flight. Always give the backend a chance to release its adapter + # resources, even if it does not yet report itself as connected. + if self.client is client: + self.client = None + with contextlib.suppress(BaseException): + await asyncio.wait_for(client.disconnect(), timeout=2.0) + raise + + # ---- auto-reconnect -------------------------------------------------- + def _handle_disconnect(self, client: BleakClient) -> None: + """Bleak disconnected callback - schedule a reconnect if unexpected. + + Runs in the BLE event-loop thread, so the reconnect is scheduled back + onto the session loop in a thread-safe way. + """ + if client is not self.client: + return # stale callback from a superseded connection attempt + if not self._should_run or not self.auto_reconnect: + return + self.logger.warning( + msg=f"{self.display_name} connection lost - attempting to reconnect..." + ) + self._emit_status(event="reconnecting", device=self.device_key) + if self._loop is not None and not self._loop.is_closed(): + self._loop.call_soon_threadsafe(self._schedule_reconnect) + + def _schedule_reconnect(self) -> None: + """Start the reconnect loop if one is not already running.""" + if self._reconnect_task and not self._reconnect_task.done(): + return + if self._loop is None: + return + self._reconnect_task = self._loop.create_task(self._reconnect_loop()) + + async def _reconnect_loop(self) -> None: + """Keep retrying the link until it succeeds or shutdown is requested.""" + while self._should_run: + try: + await self._open_link() + await self._after_connect() + self.logger.info(msg=f"{self.display_name} reconnected") + self._emit_status(event="connected", device=self.device_key) + return + except Exception as e: + self.logger.warning( + msg=f"{self.display_name} reconnect failed, retrying: {e}" + ) + await asyncio.sleep(delay=self.reconnect_delay) + + # ---- teardown -------------------------------------------------------- + async def disconnect(self) -> None: + """Disconnect and stop any auto-reconnect attempts.""" + # Signal intentional shutdown so the disconnected callback does not + # trigger a reconnect. + self._should_run = False + + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + with contextlib.suppress(BaseException): + await self._reconnect_task + + client, self.client = self.client, None + if client is not None: + try: + # Do not gate cleanup on is_connected: a cancelled or failed + # connection can still own backend resources while reporting + # False. Bounded cleanup keeps shutdown responsive. + await asyncio.wait_for(client.disconnect(), timeout=1.0) + self.logger.debug(msg=f"Disconnected from {self.display_name}") + except TimeoutError: + self.logger.warning(msg="Disconnect timed out - forcing close") + except Exception as e: + self.logger.error(msg=f"Error during disconnect: {e}") + + @property + def is_connected(self) -> bool: + """Check if currently connected.""" + return self.client is not None and self.client.is_connected diff --git a/app/coerce.py b/app/coerce.py new file mode 100644 index 0000000..61329bd --- /dev/null +++ b/app/coerce.py @@ -0,0 +1,21 @@ +"""Shared best-effort numeric coercion helpers. + +Values arrive from sources the app does not fully control (BLE packets, +hand-edited CSV cells, config), so callers need one consistent conversion that +returns ``None`` instead of raising. + +Stdlib-only on purpose: modules like ``app.segmentation`` that stay +dependency-free for unit tests can import it without pulling anything else in. +""" + +from typing import Any + + +def to_float(value: Any) -> float | None: + """Best-effort float coercion; returns None for missing/invalid values.""" + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9ff60f0 --- /dev/null +++ b/app/config.py @@ -0,0 +1,32 @@ +"""Typed accessors for the raw config dict. + +``config.yml`` values arrive untyped (YAML scalars, often strings like +``"yes"``). Boolean-ish settings used to be parsed ad hoc at every call site +with slightly diverging accepted spellings; this module is the single place +that defines what counts as true. +""" + +from collections.abc import Mapping +from typing import Any + +# Accepted truthy spellings for boolean-ish config values, lowercased. +_TRUTHY = frozenset({"yes", "y", "true", "1", "on"}) + + +def parse_bool(value: Any, default: bool = False) -> bool: + """Interpret a config-style boolean value. + + Real booleans pass through; anything else is stringified and matched + against the accepted truthy spellings (``yes``/``y``/``true``/``1``/``on``, + case-insensitive). ``None`` returns ``default``. + """ + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in _TRUTHY + + +def get_bool(config: Mapping[str, Any], key: str, default: bool = False) -> bool: + """Read a boolean-ish value from a config mapping (see :func:`parse_bool`).""" + return parse_bool(config.get(key), default=default) diff --git a/app/events.py b/app/events.py new file mode 100644 index 0000000..090ce2c --- /dev/null +++ b/app/events.py @@ -0,0 +1,86 @@ +"""Typed event contracts crossing worker, session, BLE, and GUI boundaries.""" + +from typing import Any, Literal, Required, TypedDict + +UiEventType = Literal[ + "metrics", "status", "workout_result", "export_result", "token_update" +] +TerminalEventType = Literal["workout_result", "export_result", "token_update"] + + +class StatusPayload(TypedDict, total=False): + """Device/session status before WorkerManager adds the top-level type.""" + + event: Required[str] + device: Required[str] + transition: str | None + message: str + level: int + saved_rows: int + minutes: float + paused: bool + lap: int + step_index: int + total: int + step_label: str | None + step_kind: str | None + target: dict[str, Any] | None + remaining: Any + remaining_kind: Any + fraction: Any + next_label: str | None + done: bool + csv_path: str + distance_m: float + active_time_s: float + elapsed_time_s: float + pause_time_s: float + record_count: int + incomplete: bool + persistence_error: str | None + + +class UiEvent(TypedDict, total=False): + """Complete event delivered on the GUI thread.""" + + type: Required[UiEventType] + data: dict[str, Any] + event: str + device: str + transition: str | None + message: str + level: int + saved_rows: int + minutes: float + paused: bool + lap: int + step_index: int + total: int + step_label: str | None + step_kind: str | None + target: dict[str, Any] | None + remaining: Any + remaining_kind: Any + fraction: Any + next_label: str | None + done: bool + outcome: str + stage: str + csv_path: str | None + fit_path: str | None + durable_rows: int + retryable: bool + activity_id: int | None + upload_id: int | None + tokens_updated: bool + new_access_token: str | None + new_refresh_token: str | None + success: bool + pending: bool + distance_m: float + active_time_s: float + elapsed_time_s: float + pause_time_s: float + record_count: int + incomplete: bool + persistence_error: str | None diff --git a/app/export_fit.py b/app/export_fit.py new file mode 100644 index 0000000..a593a7f --- /dev/null +++ b/app/export_fit.py @@ -0,0 +1,545 @@ +"""CSV -> Garmin FIT conversion for kayaking activities. + +Converts recorded workout data (a Table read from the workout CSV) into a +Garmin FIT activity file via ``fit_tool``. Exact timer runs come from the +recorded activity flag; finalized laps are one per recorded program step for +planned workouts, while free workouts group runs across inactive gaps shorter +than the fixed meaningful-pause delay. +Session/lap statistics use the shared finalized metric result, so the exported +numbers always match the in-app summary screen. Each active run +gets proper START/STOP_ALL timer events (moving vs elapsed time) for accurate +Strava calculations. Synthetic GPS positions map the same active-only distance +onto the 2 km Hazewinkel course because Strava uses positions to calculate the +overview moving time for kayaking. A metric-free position at every active-run +start anchors the first represented sample window. Standard sensor metrics are +written: heart rate, cadence, power, distance and speed. +""" + +import os +import traceback +from datetime import datetime +from pathlib import Path +from typing import Any + +from fit_tool.fit_file_builder import FitFileBuilder +from fit_tool.profile.messages.activity_message import ActivityMessage +from fit_tool.profile.messages.device_info_message import DeviceInfoMessage +from fit_tool.profile.messages.event_message import EventMessage +from fit_tool.profile.messages.file_id_message import FileIdMessage +from fit_tool.profile.messages.lap_message import LapMessage +from fit_tool.profile.messages.record_message import RecordMessage +from fit_tool.profile.messages.session_message import SessionMessage +from fit_tool.profile.profile_type import ( + Activity, + Event, + EventType, + FileType, + Manufacturer, + SessionTrigger, + Sport, + SubSport, + TimerTrigger, +) + +from .finalized_workout import ActivitySegment, FinalizedWorkout, finalize_workout +from .logger import Logger +from .power import DEFAULT_PULL_LENGTH_M, estimate_power, normalize_pull_length +from .read_csv import CsvReader, CsvReadError +from .segmentation import load_program_steps +from .speed_series import recorded_speed_mps +from .table import Table +from .workout_metadata import load_workout_metadata +from .workout_metrics import SliceMetrics, compute_finalized_metrics +from .workout_paths import WorkoutPaths + +# Strava recalculates a kayaking activity's overview moving time from GPS +# positions instead of reliably honoring the FIT session timer. Map KayakFit's +# authoritative active-only distance onto this deterministic out-and-back +# course so a pause remains stationary in Strava without changing FIT totals. +_GPS_COURSE_START = (51.061847, 4.403456) # (latitude, longitude) in degrees +_GPS_COURSE_END = (51.069048, 4.376805) +_GPS_COURSE_LENGTH_M = 2000.0 + + +def _synthetic_gps_position(distance_m: float) -> tuple[float, float]: + """Map cumulative active distance onto the Hazewinkel out-and-back course.""" + cycle_length_m = 2 * _GPS_COURSE_LENGTH_M + position_in_cycle_m = distance_m % cycle_length_m + if position_in_cycle_m <= _GPS_COURSE_LENGTH_M: + progress = position_in_cycle_m / _GPS_COURSE_LENGTH_M + else: + progress = (cycle_length_m - position_in_cycle_m) / _GPS_COURSE_LENGTH_M + + start_lat, start_lon = _GPS_COURSE_START + end_lat, end_lon = _GPS_COURSE_END + return ( + start_lat + (end_lat - start_lat) * progress, + start_lon + (end_lon - start_lon) * progress, + ) + + +class FitExporter: + """Export workout data to FIT format for indoor kayaking.""" + + def __init__(self, pull_length_m: Any = DEFAULT_PULL_LENGTH_M) -> None: + """Initialize the FIT exporter with recorded power calibration.""" + self.pull_length_m = normalize_pull_length(pull_length_m) + self.logger = Logger.get_logger(name=__name__) + + @staticmethod + def convert_csv_to_fit(file_path: str) -> dict[str, Any]: + """Convert a CSV file to FIT format. + + Args: + file_path: Path to the CSV file. + + Returns: + {"success": True, "file_path": Path(...)} on success or + {"success": False, "message": "..."} on failure. + """ + try: + file_path_obj = Path(file_path) + metadata = load_workout_metadata(file_path_obj) + processing_config = metadata["processing_config"] + assert isinstance(processing_config, dict) + csv_reader = CsvReader(file_path=str(file_path_obj)) + workout_df = csv_reader.read_all() + + if workout_df.is_empty(): + return { + "success": False, + "message": "CSV file contains no valid workout data", + } + + fit_path = WorkoutPaths.from_csv(file_path_obj).fit + program_steps = ( + load_program_steps(file_path_obj) + if metadata["workout_mode"] == "planned" + else None + ) + if metadata["workout_mode"] == "planned" and program_steps is None: + raise ValueError("Planned v1 workout is missing its step timeline") + exporter = FitExporter(processing_config["pull_length_m"]) + success = exporter.export( + workout_df=workout_df, + output_path=str(fit_path), + program_steps=program_steps, + ) + + if not success: + return { + "success": False, + "message": "Failed to convert CSV to FIT format", + } + + return {"success": True, "file_path": fit_path} + + except FileNotFoundError as e: + return {"success": False, "message": f"CSV file not found: {e!s}"} + except CsvReadError as e: + # Expected, user-facing failure (bad/empty/malformed CSV); no stack trace. + return {"success": False, "message": str(e)} + except Exception as e: + traceback.print_exc() + return { + "success": False, + "message": f"Error converting CSV to FIT: {e!s}", + } + + def export( + self, + workout_df: Table, + output_path: str, + program_steps: list[dict[str, Any]] | None = None, + ) -> bool: + """Export workout data to FIT file. + + Args: + workout_df: Table of recorded sensor samples. + output_path: Path to output .fit file. + program_steps: Optional program step timeline; when present the FIT + laps follow the program's steps instead of auto-pause segments. + + Returns: + True if export successful, False otherwise. + """ + if workout_df.height < 2: + self.logger.warning(msg="Not enough data to create FIT file") + return False + + try: + self.logger.info(msg=f"Exporting to FIT format: {output_path}") + + finalized = finalize_workout( + workout_df, + program_steps=program_steps, + logger=self.logger, + ) + if not finalized.segments: + self.logger.warning(msg="No moving segments found; FIT export skipped") + return False + finalized_metrics = compute_finalized_metrics( + workout_df, + finalized, + pull_length_m=self.pull_length_m, + ) + fit_start = datetime.fromtimestamp( + finalized.timeline_start_time_ms / 1000 + ) + + builder = FitFileBuilder(auto_define=True, min_string_size=50) + + self._add_file_id(builder=builder, start_time=fit_start) + self._add_device_info(builder=builder, start_time=fit_start) + # Add records and get segment information + record_segments = self._add_records( + builder=builder, workout_df=workout_df, finalized=finalized + ) + + # Calculate overall statistics + first_start_ms = finalized.timeline_start_time_ms + last_end_ms = finalized.timeline_end_time_ms + total_timer_time_ms = finalized.total_timer_time_ms + total_elapsed_time_ms = finalized.total_elapsed_time_ms + + # Timer events follow every exact active run. Free laps can group + # brief gaps; planned laps group active records by performed step. + for lap, metrics in zip( + finalized.laps, finalized_metrics.laps, strict=True + ): + self._add_lap( + builder=builder, + start_time_ms=lap.start_time_ms, + end_time_ms=lap.end_time_ms, + timer_time_ms=lap.timer_time_ms, + distance_m=lap.distance_m, + metrics=metrics, + ) + + # Add overall session summary - MUST be after laps for proper FIT structure + self._add_session( + builder=builder, + start_time_ms=first_start_ms, + end_time_ms=last_end_ms, + total_timer_time_ms=total_timer_time_ms, + total_elapsed_time_ms=total_elapsed_time_ms, + total_distance_m=finalized.total_distance_m, + num_laps=len(finalized.laps), + metrics=finalized_metrics.session, + ) + self._add_activity( + builder=builder, + end_time_ms=last_end_ms, + total_timer_time_ms=total_timer_time_ms, + ) + + self.logger.info( + msg=( + f"Created {len(finalized.laps)} laps from " + f"{len(record_segments)} active run(s), " + f"total_timer_time={total_timer_time_ms / 1000:.0f}s, " + f"total_elapsed_time={total_elapsed_time_ms / 1000:.0f}s" + ) + ) + + fit_file = builder.build() + # Never destroy a previously valid FIT before the replacement has + # been completely written. The sibling temporary file is atomically + # promoted only after fit_tool finishes successfully. + output = Path(output_path) + temp_output = output.with_name(output.name + ".tmp") + try: + fit_file.to_file(path=str(temp_output)) + os.replace(temp_output, output) + finally: + if temp_output.exists(): + temp_output.unlink() + + records_written = len(finalized.moving_indices) + self.logger.info( + msg=( + f"Successfully created FIT file with {records_written} " + f"sensor records, {len(finalized.segments)} GPS anchors " + f"and {len(finalized.laps)} laps" + ) + ) + return True + + except Exception as e: + self.logger.error(msg=f"Error creating FIT file: {e}") + traceback.print_exc() + return False + + def _add_file_id(self, builder: FitFileBuilder, start_time: datetime) -> None: + """Add File ID message. + + Args: + builder: FIT file builder instance. + start_time: Workout start time. + """ + file_id = FileIdMessage() + file_id.type = FileType.ACTIVITY + file_id.manufacturer = Manufacturer.DEVELOPMENT.value + file_id.product = 0 + file_id.time_created = round(start_time.timestamp() * 1000) + file_id.serial_number = 0x4B594B46 # "KYKF" in hex + file_id.product_name = "KayakFit" + builder.add(message=file_id) + + def _add_device_info(self, builder: FitFileBuilder, start_time: datetime) -> None: + """Add Device Info message for KayakFirst Bull. + + Args: + builder: FIT file builder instance. + start_time: Workout start time. + """ + device_info = DeviceInfoMessage() + device_info.timestamp = round(start_time.timestamp() * 1000) + device_info.manufacturer = Manufacturer.DEVELOPMENT.value + device_info.product = 0 + device_info.serial_number = 0x4B594B46 # "KYKF" in hex + device_info.device_index = 0 + device_info.product_name = "KayakFirst Bull" + builder.add(message=device_info) + + def _add_records( + self, + builder: FitFileBuilder, + workout_df: Table, + finalized: FinalizedWorkout | None = None, + ) -> tuple[ActivitySegment, ...]: + """Add active data records with exact activity-run timer events. + + Args: + builder: FIT file builder instance. + workout_df: Table of workout data points. + finalized: Optional pre-resolved authoritative timeline. + + Returns: + Exact typed activity segments used for timer events. + """ + self.logger.info(msg=f"Adding {workout_df.height} data records...") + + finalized = finalized or finalize_workout(workout_df, logger=self.logger) + timestamps_ms = finalized.timestamps_ms + + # Materialize columns to Python lists once for fast positional access. + # FIT cadence and estimated power retain the instantaneous packet + # values used by the live dashboard and finalized summary. + heart_rate_col = workout_df.get_column_or_none("heart_rate") + cadence_col = workout_df.get_column_or_none("cadence_instant") + power_pull_force_col = workout_df.get_column_or_none("pull_force_instant") + # FIT record speed is the same raw instantaneous channel used by the + # summary and chart. + speed_by_index = recorded_speed_mps(workout_df, finalized) + + # The shared finalizer supplies the exact same activity segments used by + # the summary, so exported and displayed splits agree by construction. + segments = finalized.segments + + def _emit_timer_event(event_type: Any, timestamp_ms: int, trigger: Any) -> None: + """Add a single TIMER event (START / STOP_ALL) at a given time.""" + event = EventMessage() + event.event = Event.TIMER + event.event_type = event_type + event.event_group = 0 + event.timer_trigger = trigger + event.timestamp = timestamp_ms + builder.add(message=event) + + def _add_gps_anchor(timestamp_ms: int, distance_m: float) -> None: + """Anchor the compatibility track at an active sample-window start.""" + anchor = RecordMessage() + anchor.timestamp = timestamp_ms + anchor.distance = distance_m + latitude, longitude = _synthetic_gps_position(distance_m) + anchor.position_lat = latitude + anchor.position_long = longitude + builder.add(message=anchor) + + def _add_record(i: int) -> float | None: + """Build and add one record message for row ``i``.""" + record = RecordMessage() + record.timestamp = timestamps_ms[i] + + if heart_rate_col is not None and heart_rate_col[i] is not None: + heart_rate = int(heart_rate_col[i]) + if 0 < heart_rate <= 255: + record.heart_rate = heart_rate + + if cadence_col is not None and cadence_col[i] is not None: + cadence = int(cadence_col[i]) + if 0 < cadence <= 255: + record.cadence = cadence + + record_distance = finalized.record_cumulative_distance_m.get(i) + if record_distance is not None and record_distance >= 0: + record.distance = record_distance + latitude, longitude = _synthetic_gps_position(record_distance) + record.position_lat = latitude + record.position_long = longitude + + record_speed = speed_by_index.get(i) + if record_speed is not None and 0 <= record_speed <= 65.535: + record.speed = record_speed + + if power_pull_force_col is not None and cadence_col is not None: + power = estimate_power( + power_pull_force_col[i], + cadence_col[i], + self.pull_length_m, + ) + if power is not None and 0 < power <= 65535: + record.power = power + + builder.add(message=record) + return record_distance if record_distance is not None else None + + # Emit events and records in strict chronological order so the file is + # well-formed for any FIT reader (not just Strava): + # [resume START] -> GPS anchor -> sensor records -> STOP_ALL + last_index = len(segments) - 1 + previous_distance_m = 0.0 + for seg_index, segment in enumerate(segments): + if seg_index == 0: + _emit_timer_event( + EventType.START, segment.start_time_ms, TimerTrigger.MANUAL + ) + else: + self.logger.debug( + msg=f"Resume START at ts={segment.start_time_ms} " + f"(segment {seg_index})" + ) + _emit_timer_event( + EventType.START, segment.start_time_ms, TimerTrigger.AUTO + ) + + # Sensor records describe intervals ending at their timestamp. A + # coordinate only at that endpoint makes Strava lose the first + # represented window of every run. This metric-free anchor supplies + # the position before that window and keeps the paused interval at + # the preceding cumulative distance. + _add_gps_anchor(segment.start_time_ms, previous_distance_m) + for i in segment.record_indices: + record_distance = _add_record(i) + if record_distance is not None and record_distance >= 0: + previous_distance_m = record_distance + + if seg_index < last_index: + self.logger.debug( + msg=f"Autopause STOP_ALL at ts={segment.end_time_ms} " + f"(segment {seg_index})" + ) + _emit_timer_event( + EventType.STOP_ALL, segment.end_time_ms, TimerTrigger.AUTO + ) + else: + # Final manual stop at the end of the workout. + _emit_timer_event( + EventType.STOP_ALL, segment.end_time_ms, TimerTrigger.MANUAL + ) + + return segments + + def _add_session( + self, + builder: FitFileBuilder, + start_time_ms: int, + end_time_ms: int, + total_timer_time_ms: int, + total_elapsed_time_ms: int, + total_distance_m: float, + num_laps: int, + metrics: SliceMetrics, + ) -> None: + """Add session summary message. + + Args: + builder: FIT file builder instance. + start_time_ms: First segment start timestamp in milliseconds. + end_time_ms: Last segment end timestamp in milliseconds. + total_timer_time_ms: Sum of all lap durations in milliseconds (moving time). + total_elapsed_time_ms: Time from first start to last stop in milliseconds (total time). + total_distance_m: Distance attributed to active ergometer windows. + num_laps: Number of laps in the session. + metrics: Finalized session aggregate shared with the summary. + """ + session = SessionMessage() + session.timestamp = end_time_ms + session.start_time = start_time_ms + session.total_elapsed_time = total_elapsed_time_ms / 1000.0 + session.total_timer_time = total_timer_time_ms / 1000.0 + session.trigger = SessionTrigger.ACTIVITY_END + session.sport = Sport.KAYAKING + session.sub_sport = SubSport.GENERIC + session.first_lap_index = 0 + session.num_laps = num_laps + + session.total_distance = total_distance_m + self._set_aggregate_fields(session, metrics) + + builder.add(message=session) + + def _add_activity( + self, + builder: FitFileBuilder, + end_time_ms: int, + total_timer_time_ms: int, + ) -> None: + """Add the required final activity summary for this single session.""" + activity = ActivityMessage() + activity.timestamp = end_time_ms + activity.total_timer_time = total_timer_time_ms / 1000.0 + activity.num_sessions = 1 + activity.type = Activity.MANUAL + activity.event = Event.ACTIVITY + activity.event_type = EventType.STOP + builder.add(message=activity) + + def _add_lap( + self, + builder: FitFileBuilder, + start_time_ms: int, + end_time_ms: int, + timer_time_ms: int, + distance_m: float, + metrics: SliceMetrics, + ) -> None: + """Add lap summary message for a specific segment. + + Args: + builder: FIT file builder instance. + start_time_ms: Segment start timestamp in milliseconds. + end_time_ms: Segment end timestamp in milliseconds. + timer_time_ms: Sum of active ergometer sample windows. + distance_m: Distance attributed to this lap's active windows. + metrics: Finalized lap aggregate shared with the summary. + """ + lap = LapMessage() + lap.timestamp = end_time_ms + lap.start_time = start_time_ms + lap.total_elapsed_time = (end_time_ms - start_time_ms) / 1000.0 + lap.total_timer_time = timer_time_ms / 1000.0 + lap.sport = Sport.KAYAKING + lap.sub_sport = SubSport.GENERIC + + lap.total_distance = distance_m + self._set_aggregate_fields(lap, metrics) + + builder.add(message=lap) + + @staticmethod + def _set_aggregate_fields(message: Any, metrics: SliceMetrics) -> None: + """Copy one finalized aggregate into a FIT session or lap message.""" + for attr, value, integer in ( + ("avg_heart_rate", metrics.avg_heart_rate, True), + ("max_heart_rate", metrics.max_heart_rate, True), + ("avg_cadence", metrics.avg_cadence, True), + ("max_cadence", metrics.max_cadence, True), + ("avg_power", metrics.avg_power, True), + ("max_power", metrics.max_power, True), + ("avg_speed", metrics.avg_speed, False), + ("max_speed", metrics.max_speed, False), + ): + if value is not None: + setattr(message, attr, round(value) if integer else value) diff --git a/app/field_mapping.py b/app/field_mapping.py new file mode 100644 index 0000000..4a2cdeb --- /dev/null +++ b/app/field_mapping.py @@ -0,0 +1,86 @@ +"""Shared field and CSV-schema definitions. + +The single place that names the KayakFirst ergometer's packet fields and the +workout CSV's columns: ``FIELD_MAP`` maps packet indices to field names, +``CSV_COLUMNS`` / ``COLUMN_TYPES`` define the CSV schema and each column's +parse type (int or decimal). Used by the BLE parser, the CSV reader/writer, and the FIT exporter so +the data structure is never defined twice. +""" + +# Columns stored as fixed-point decimals; everything else is an integer. +_DECIMAL_COLUMNS = { + "col_4", + "col_5", + "distance__m", + "session_elapsed__s", + "sample_duration__s", + "speed__mps", + "speed_instant__mps", +} + +# Ordered list of every CSV column, and each column's parse type +# ("int" or "decimal"). +CSV_COLUMNS: list[str] = [ + "timestamp", + "session_elapsed__s", + "sample_duration__s", + "heart_rate__bpm", + "kayakfirst_timestamp", + "col_2", + "col_3", + "col_4", + "col_5", + "col_6", + "active_paddling", + "col_8", + "distance__m", + "speed__mps", + "speed_instant__mps", + "cadence__spm", + "cadence_instant__spm", + "pace_200m__s", + "pace_200m_instant__s", + "pace_500m__s", + "pace_500m_instant__s", + "pace_1000m__s", + "pace_1000m_instant__s", + "pull_force__n", + "pull_force_instant__n", + "elapsed_time__s", + "window_size__s", +] + +COLUMN_TYPES: dict[str, str] = { + col: ("decimal" if col in _DECIMAL_COLUMNS else "int") for col in CSV_COLUMNS +} + +FIELD_MAP: dict[int, str] = { + 1: "kayakfirst_timestamp", + 2: "col_2", + 3: "col_3", + 4: "col_4", + 5: "col_5", + 6: "col_6", + # 1 while strokes are being detected, 0 when idle (also 0 on the stale + # repeated packets the erg sends after stopping, making it more reliable + # than the frozen speed/distance fields there). + 7: "active_paddling", + 8: "col_8", + 9: "distance__m", + 10: "speed__mps", + 11: "speed_instant__mps", + 12: "cadence__spm", + 13: "cadence_instant__spm", + 14: "pace_200m__s", + 15: "pace_200m_instant__s", + 16: "pace_500m__s", + 17: "pace_500m_instant__s", + 18: "pace_1000m__s", + 19: "pace_1000m_instant__s", + 20: "pull_force__n", + 21: "pull_force_instant__n", + 22: "elapsed_time__s", + # Seconds covered by this datapoint: 1 normally, 2 when a poll was missed, + # larger on the first poll (covers everything since the workout started). + 23: "window_size__s", +} diff --git a/app/finalized_workout.py b/app/finalized_workout.py new file mode 100644 index 0000000..2ea16f2 --- /dev/null +++ b/app/finalized_workout.py @@ -0,0 +1,502 @@ +"""Single finalized timeline shared by FIT export and workout summaries.""" + +from dataclasses import dataclass, replace +from math import floor, isfinite +from typing import Any + +from .active_distance import ActiveDistanceAccumulator +from .segmentation import ( + MEANINGFUL_PAUSE_SECONDS, + program_step_bounds, + resolve_segments, +) +from .table import Table + + +@dataclass(frozen=True) +class ActivitySegment: + """One exact contiguous run of active ergometer records.""" + + start_idx: int + end_idx: int + start_time_ms: int + end_time_ms: int + timer_time_ms: int + record_indices: tuple[int, ...] + +@dataclass(frozen=True) +class FinalizedLap: + """One immutable reporting/FIT lap over zero or more active records.""" + + start_idx: int + end_idx: int + start_time_ms: int + end_time_ms: int + timer_time_ms: int + record_indices: tuple[int, ...] + record_weights_s: tuple[float, ...] + distance_m: float + kind: str | None = None + label: str | None = None + program_step_index: int | None = None + +@dataclass(frozen=True) +class FinalizedWorkout: + """The authoritative record/segment domain of a processed workout.""" + + timestamps_ms: list[int] + timeline_start_time_ms: int + timeline_end_time_ms: int + record_windows_ms: dict[int, tuple[int, int]] + # Exact contiguous runs of active FIT records. These own timer events. + segments: tuple[ActivitySegment, ...] + # Presentation/FIT lap groups. A lap can contain multiple active runs. + laps: tuple[FinalizedLap, ...] + # Row indices where chart lines restart (meaningful free-lap boundaries or + # every exact active run for planned workouts). + chart_break_indices: tuple[int, ...] + moving_indices: tuple[int, ...] + # Active-only cumulative distance written to each FIT record. Missing raw + # readings stay None so exporters do not invent a measurement. + record_cumulative_distance_m: dict[int, float | None] + # Active distance attributed to each represented record window. A missing + # value means the trailing odometer span never received a closing reading. + record_distance_m: dict[int, float | None] + record_duration_s: dict[int, float] + total_timer_time_ms: int + total_elapsed_time_ms: int + total_distance_m: float + + @property + def start_time_ms(self) -> int | None: + """Timestamp of the first FIT timer START event.""" + return self.segments[0].start_time_ms if self.segments else None + + @property + def end_time_ms(self) -> int | None: + """Timestamp of the final active FIT record/STOP event.""" + return self.segments[-1].end_time_ms if self.segments else None + + @property + def total_pause_time_ms(self) -> int: + """Wall-clock time inside the session that was not active timer time.""" + return max(self.total_elapsed_time_ms - self.total_timer_time_ms, 0) + + +def _build_activity_domain( + record_windows_ms: dict[int, tuple[int, int]], + resolved: list[dict[str, Any]], +) -> tuple[ + list[ActivitySegment], + list[int], + dict[int, int], +]: + """Materialize exact active runs from canonical represented intervals.""" + segments: list[ActivitySegment] = [] + record_timer_ms: dict[int, int] = {} + for raw_segment in resolved: + indices = tuple(int(index) for index in raw_segment["record_indices"]) + represented_windows_ms: list[int] = [] + for index in indices: + window_start_ms, window_end_ms = record_windows_ms[index] + represented_ms = window_end_ms - window_start_ms + if represented_ms <= 0: + raise ValueError( + f"Workout is not v1: elapsed interval must be positive at row {index}" + ) + represented_windows_ms.append(represented_ms) + record_timer_ms[index] = represented_ms + first_index = indices[0] + last_index = indices[-1] + adjusted = ActivitySegment( + start_idx=first_index, + end_idx=last_index, + start_time_ms=record_windows_ms[first_index][0], + end_time_ms=record_windows_ms[last_index][1], + timer_time_ms=sum(represented_windows_ms), + record_indices=indices, + ) + if adjusted.timer_time_ms > adjusted.end_time_ms - adjusted.start_time_ms: + raise ValueError("Workout is not v1: active timer exceeds elapsed time") + segments.append(adjusted) + moving_indices = [ + index for segment in segments for index in segment.record_indices + ] + return segments, moving_indices, record_timer_ms + + +def _build_record_timeline( + table: Table, raw_timestamps_ms: list[int] +) -> tuple[list[int], dict[int, tuple[int, int]], int, int]: + """Validate and materialize the canonical CSV timing fields.""" + elapsed_values = table.get_column_or_none("session_elapsed") + sample_durations = table.get_column_or_none("sample_duration") + if elapsed_values is None or sample_durations is None: + raise ValueError( + "Workout is not v1: session_elapsed and sample_duration are required" + ) + if len(elapsed_values) != len(raw_timestamps_ms): + raise ValueError("Workout is not v1: incomplete canonical timeline") + + elapsed_ms: list[int] = [] + windows: dict[int, tuple[int, int]] = {} + origin_ms: int | None = None + previous_endpoint_ms = 0 + for index, (timestamp, elapsed_value, duration_value) in enumerate( + zip(raw_timestamps_ms, elapsed_values, sample_durations, strict=True) + ): + try: + endpoint_ms = round(float(elapsed_value) * 1000) + represented_ms = round(float(duration_value) * 1000) + except (OverflowError, TypeError, ValueError) as exc: + raise ValueError( + f"Workout is not v1: invalid canonical timing at row {index}" + ) from exc + if endpoint_ms < 0 or represented_ms < 0 or represented_ms > endpoint_ms: + raise ValueError( + f"Workout is not v1: invalid canonical timing at row {index}" + ) + if index and endpoint_ms < elapsed_ms[-1]: + raise ValueError( + f"Workout is not v1: session_elapsed must not decrease at row {index}" + ) + if origin_ms is None: + origin_ms = timestamp - endpoint_ms + if timestamp != origin_ms + endpoint_ms: + raise ValueError( + f"Workout is not v1: timestamp disagrees with session_elapsed at row {index}" + ) + start_ms = timestamp - represented_ms + previous_timestamp_ms = origin_ms + previous_endpoint_ms + if index and start_ms < previous_timestamp_ms: + raise ValueError( + f"Workout is not v1: sample windows overlap at row {index}" + ) + windows[index] = (start_ms, timestamp) + elapsed_ms.append(endpoint_ms) + previous_endpoint_ms = endpoint_ms + assert origin_ms is not None + return raw_timestamps_ms, windows, origin_ms, origin_ms + elapsed_ms[-1] + + +def _calculate_active_distance( + distances: list[Any], + moving_indices: list[int], + record_timer_ms: dict[int, int], +) -> tuple[dict[int, float | None], dict[int, float | None], float]: + """Attribute cumulative-distance deltas only to active record windows. + + Each active interval owns the raw odometer advance since the preceding + record. Inactive rows advance only the baseline. No speed-based cap rewrites + a recorded distance jump. + """ + record_distance_m: dict[int, float | None] = {} + cumulative: dict[int, float | None] = {} + moving_set = set(moving_indices) + accumulator = ActiveDistanceAccumulator() + pending_indices: list[int] = [] + for index, value in enumerate(distances): + active = index in moving_set + if active: + pending_indices.append(index) + update = accumulator.update( + distance_m=value, + duration_s=record_timer_ms.get(index, 0) / 1000.0, + active=active, + ) + if not active: + for pending_index in pending_indices: + record_distance_m[pending_index] = None + cumulative[pending_index] = None + pending_indices = [] + continue + try: + has_distance = isfinite(float(value)) and float(value) >= 0 + except (TypeError, ValueError): + has_distance = False + if not has_distance: + cumulative[index] = None + if update is None: + continue + + remaining_distance_m = update.distance_m + remaining_duration_s = update.duration_s + for position, pending_index in enumerate(pending_indices): + represented_s = record_timer_ms.get(pending_index, 0) / 1000.0 + if position == len(pending_indices) - 1 or remaining_duration_s <= 0: + attributed_m = remaining_distance_m + else: + attributed_m = update.distance_m * represented_s / update.duration_s + record_distance_m[pending_index] = attributed_m + remaining_distance_m -= attributed_m + remaining_duration_s -= represented_s + cumulative[index] = round(update.cumulative_distance_m, 2) + pending_indices = [] + + for pending_index in pending_indices: + record_distance_m[pending_index] = None + cumulative[pending_index] = None + return record_distance_m, cumulative, round(accumulator.active_distance_m, 2) + + +def _build_planned_laps( + program_steps: list[dict[str, Any]] | None, + moving_indices: list[int], + record_windows_ms: dict[int, tuple[int, int]], + record_timer_ms: dict[int, int], + record_distance_m: dict[int, float | None], + timeline_start_ms: int, + timeline_end_ms: int, +) -> list[FinalizedLap]: + """Intersect active sample windows with the performed program timeline.""" + if program_steps is None: + return [] + previous_step_end_ms = timeline_start_ms + moving_cursor = 0 + laps: list[FinalizedLap] = [] + for ordinal, step in enumerate(program_steps): + bounds = program_step_bounds(step) + assert bounds is not None + start_seconds, end_seconds = bounds + step_start_ms = timeline_start_ms + round(start_seconds * 1000) + step_end_ms = timeline_start_ms + round(end_seconds * 1000) + if step_start_ms != previous_step_end_ms or step_end_ms > timeline_end_ms: + raise ValueError("Planned v1 workout has a non-consecutive timeline") + previous_step_end_ms = step_end_ms + record_indices: list[int] = [] + record_weights_s: list[float] = [] + distance_m = 0.0 + while moving_cursor < len(moving_indices): + candidate = moving_indices[moving_cursor] + if record_windows_ms[candidate][1] > step_start_ms: + break + moving_cursor += 1 + record_cursor = moving_cursor + while record_cursor < len(moving_indices): + index = moving_indices[record_cursor] + record_start_ms, record_end_ms = record_windows_ms[index] + if record_start_ms >= step_end_ms: + break + duration_ms = record_timer_ms.get(index, 0) + overlap_ms = max( + min(record_end_ms, step_end_ms) - max(record_start_ms, step_start_ms), + 0, + ) + if duration_ms > 0 and overlap_ms > 0: + record_indices.append(index) + record_weights_s.append(overlap_ms / 1000.0) + distance_m += (record_distance_m.get(index) or 0.0) * ( + overlap_ms / duration_ms + ) + record_cursor += 1 + laps.append(FinalizedLap( + start_idx=record_indices[0] if record_indices else 0, + end_idx=record_indices[-1] if record_indices else -1, + start_time_ms=step_start_ms, + end_time_ms=step_end_ms, + timer_time_ms=round(sum(record_weights_s) * 1000), + record_indices=tuple(record_indices), + record_weights_s=tuple(record_weights_s), + distance_m=distance_m, + kind=step.get("type"), + label=step.get("label"), + program_step_index=step.get("index", ordinal), + )) + return laps + + +def _group_free_segments( + segments: list[ActivitySegment], +) -> list[list[ActivitySegment]]: + """Group exact runs by the shared meaningful-pause boundary.""" + groups: list[list[ActivitySegment]] = [] + pause_threshold_ms = round(MEANINGFUL_PAUSE_SECONDS * 1000) + for segment in segments: + if not groups: + groups.append([segment]) + continue + previous = groups[-1][-1] + inactive_ms = segment.start_time_ms - previous.end_time_ms + if inactive_ms < pause_threshold_ms: + groups[-1].append(segment) + else: + groups.append([segment]) + return groups + + +def _build_free_laps( + segments: list[ActivitySegment], + record_timer_ms: dict[int, int], + record_distance_m: dict[int, float | None], +) -> list[FinalizedLap]: + """Group exact runs separated by less than one meaningful free pause.""" + groups = _group_free_segments(segments) + + laps: list[FinalizedLap] = [] + for group in groups: + first, last = group[0], group[-1] + indices = tuple( + index for segment in group for index in segment.record_indices + ) + laps.append(FinalizedLap( + start_idx=first.start_idx, + end_idx=last.end_idx, + start_time_ms=first.start_time_ms, + end_time_ms=last.end_time_ms, + timer_time_ms=sum(segment.timer_time_ms for segment in group), + record_indices=indices, + record_weights_s=tuple( + record_timer_ms.get(index, 0) / 1000.0 for index in indices + ), + distance_m=sum((record_distance_m.get(index) or 0.0) for index in indices), + )) + return laps + + +def _chart_breaks(segments: list[ActivitySegment]) -> set[int]: + """Restart chart lines only at meaningful pauses, for planned and free alike. + + A break starts a new summary polyline. Both workout modes group exact runs + separated by less than the fixed five-second meaningful-pause delay + (``_group_free_segments``). A brief cleared activity flag between strokes or + a sub-threshold uncovered gap therefore does not fragment one continuous + effort into disconnected segments. The latter is common at high cadence, + when the ergometer reports a one-second window across a two-second elapsed + advance and leaves one second uncovered inside otherwise unbroken paddling. + Exact runs, timer time, distance, and laps are unaffected; only line + continuity changes. Planned laps still come from performed steps and render + as chart bands rather than line breaks (D019). + """ + return {group[0].start_idx for group in _group_free_segments(segments)} + + +def _conserve_lap_totals( + laps: list[FinalizedLap], timer_ms: int, total_distance: float, planned: bool +) -> list[FinalizedLap]: + """Validate lap conservation and apportion centimetre rounding.""" + lap_timer_ms = sum(lap.timer_time_ms for lap in laps) + lap_distance_m = sum(lap.distance_m for lap in laps) + if lap_timer_ms != timer_ms: + label = "Planned timeline" if planned else "Free-workout laps" + raise ValueError(f"{label} do not cover the complete active timer domain") + if abs(lap_distance_m - total_distance) > 0.011: + label = "Planned timeline" if planned else "Free-workout laps" + raise ValueError(f"{label} do not cover the complete active distance") + if not laps: + return [] + exact_cents = [max(lap.distance_m, 0.0) * 100 for lap in laps] + lap_cents = [floor(value) for value in exact_cents] + remaining = round(total_distance * 100) - sum(lap_cents) + remainders = sorted( + range(len(laps)), + key=lambda index: exact_cents[index] - lap_cents[index], + reverse=True, + ) + for index in remainders[:remaining]: + lap_cents[index] += 1 + return [ + replace(lap, distance_m=cents / 100.0) + for lap, cents in zip(laps, lap_cents, strict=True) + ] + + +def finalize_workout( + table: Table, + *, + program_steps: list[dict[str, Any]] | None = None, + logger: Any | None = None, +) -> FinalizedWorkout: + """Resolve the exact records, segments, timing and distance written to FIT.""" + if program_steps is not None: + if not program_steps: + raise ValueError("Planned v1 workout requires a non-empty step timeline") + previous_end = 0.0 + for ordinal, step in enumerate(program_steps): + bounds = program_step_bounds(step) + if ( + bounds is None + or step.get("index") != ordinal + or bounds[0] != previous_end + ): + raise ValueError( + f"Planned v1 workout has an invalid step at index {ordinal}" + ) + previous_end = bounds[1] + try: + raw_timestamps = [] + for value in table.get_column("timestamp"): + timestamp = int(value) + if isinstance(value, bool) or float(value) != timestamp: + raise ValueError + raw_timestamps.append(timestamp) + except (OverflowError, TypeError, ValueError) as exc: + raise ValueError("Workout is not v1: every timestamp must be an integer") from exc + if not raw_timestamps: + raise ValueError("Workout contains no records") + timestamps, record_windows_ms, timeline_start_ms, timeline_end_ms = ( + _build_record_timeline(table, raw_timestamps) + ) + if program_steps is not None: + _, final_end_s = program_step_bounds(program_steps[-1]) or (0.0, -1.0) + timeline_elapsed_s = (timeline_end_ms - timeline_start_ms) / 1000.0 + if round(final_end_s * 1000) != round(timeline_elapsed_s * 1000): + raise ValueError("Planned v1 workout must cover the complete session timeline") + # The activity record/timer domain is independent of a workout plan. A + # plan groups those records later; it must never add/remove sensor samples. + active_values = table.get_column_or_none("active") + if active_values is None: + raise ValueError("Workout is not v1: active_paddling is required for every row") + resolved = resolve_segments( + record_windows_ms=record_windows_ms, + active_col=active_values, + logger=logger, + ) + segments, moving_indices, record_timer_ms = _build_activity_domain( + record_windows_ms, resolved + ) + distances = table.get_column("distance") + record_distance_m, active_cumulative_distance_m, total_distance = ( + _calculate_active_distance( + distances, + moving_indices, + record_timer_ms, + ) + ) + laps = _build_planned_laps( + program_steps, moving_indices, record_windows_ms, + record_timer_ms, record_distance_m, timeline_start_ms, timeline_end_ms, + ) + planned_laps = program_steps is not None + if not planned_laps: + laps = _build_free_laps( + segments, record_timer_ms, record_distance_m + ) + chart_breaks = _chart_breaks(segments) + timer_ms = sum(segment.timer_time_ms for segment in segments) + elapsed_ms = timeline_end_ms - timeline_start_ms + laps = _conserve_lap_totals(laps, timer_ms, total_distance, planned_laps) + if timer_ms > elapsed_ms: + raise ValueError("Workout is not v1: total timer time exceeds elapsed time") + if any( + lap.timer_time_ms > lap.end_time_ms - lap.start_time_ms for lap in laps + ): + raise ValueError("Workout is not v1: lap timer time exceeds elapsed time") + return FinalizedWorkout( + timestamps_ms=timestamps, + timeline_start_time_ms=timeline_start_ms, + timeline_end_time_ms=timeline_end_ms, + record_windows_ms=record_windows_ms, + segments=tuple(segments), + laps=tuple(laps), + chart_break_indices=tuple(sorted(chart_breaks)), + moving_indices=tuple(moving_indices), + record_cumulative_distance_m=active_cumulative_distance_m, + record_distance_m=record_distance_m, + record_duration_s={ + index: duration_ms / 1000.0 + for index, duration_ms in record_timer_ms.items() + }, + total_timer_time_ms=timer_ms, + total_elapsed_time_ms=elapsed_ms, + total_distance_m=total_distance, + ) diff --git a/app/heart_rate_monitor_bluetooth.py b/app/heart_rate_monitor_bluetooth.py new file mode 100644 index 0000000..5769c37 --- /dev/null +++ b/app/heart_rate_monitor_bluetooth.py @@ -0,0 +1,117 @@ +"""Standard Bluetooth heart-rate monitor handler. + +Built on ``BleDevice`` (retrying connect, auto-reconnect, graceful +disconnect): subscribes to the standard BLE Heart Rate Service (UUID 180D) +measurement notifications, parses the uint8/uint16 heart-rate flag per spec, +tolerates truncated frames from flaky straps, reads the battery level once +after every (re)connect, and delivers each reading through a callback. +Compatible with any heart-rate monitor implementing the standard service. +""" + +from collections.abc import Callable +from typing import Any + +from bleak.backends.device import BLEDevice + +from .ble_device import BleDevice +from .events import StatusPayload + +HEART_RATE_MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb" +# Standard BLE Battery Service / Battery Level characteristic. +BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb" + + +class HeartRateMonitor(BleDevice): + """Bluetooth Heart Rate Service monitor handler.""" + + NOTIFY_UUID = HEART_RATE_MEASUREMENT + device_key = "hrm" + display_name = "HRM" + + def __init__( + self, + address: str, + log_level: str, + connect_timeout: float = 15.0, + connect_retries: int = 3, + auto_reconnect: bool = True, + reconnect_delay: float = 5.0, + status_callback: Callable[[StatusPayload], None] | None = None, + ble_device: BLEDevice | None = None, + ) -> None: + """Initialize HRM handler. + + Args: + address: Bluetooth device address. + log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). + connect_timeout: Per-attempt BLE connection timeout in seconds. + connect_retries: Number of connection attempts before giving up. + auto_reconnect: Reconnect automatically if the link drops mid-session. + reconnect_delay: Delay between reconnect cycles in seconds. + status_callback: Optional callback receiving connection status events + (dicts with ``event`` and ``device`` keys). + ble_device: Already-discovered HRM used to avoid an implicit scan. + """ + super().__init__( + address=address, + ble_device=ble_device, + log_level=log_level, + connect_timeout=connect_timeout, + connect_retries=connect_retries, + auto_reconnect=auto_reconnect, + reconnect_delay=reconnect_delay, + status_callback=status_callback, + ) + + async def _after_connect(self) -> None: + """Read the battery level once after every (re)connect.""" + await self._read_battery_level() + + async def _read_battery_level(self) -> None: + """Read the strap's battery level once (best-effort; not all expose it).""" + if self.client is None: + return + try: + raw = await self.client.read_gatt_char(BATTERY_LEVEL) + if raw: + level = int(raw[0]) + self.logger.info(msg=f"HRM battery level: {level}%") + self._emit_status(event="battery", device="hrm", level=level) + except Exception as e: + self.logger.debug(msg=f"HRM battery level unavailable: {e}") + + def _on_notify(self, sender: Any, data: bytearray) -> None: + """Parse heart rate measurement data. + + Heart Rate Measurement format (BLE standard): + - Byte 0: Flags + - Bit 0: HR format (0 = uint8, 1 = uint16) + - Bit 1-2: Sensor contact status + - Bit 3: Energy expended present + - Bit 4: RR-Interval present + - Byte 1+: Heart rate value + + Args: + sender: Characteristic handle that sent the notification. + data: Raw heart rate measurement data. + """ + # Guard against truncated packets from flaky straps: a short read here + # would raise inside the BLE notification callback. + if len(data) < 2: + self.logger.debug(msg=f"Ignoring malformed HR packet: {data.hex()}") + return + flags = data[0] + hr_format = flags & 0x01 + + if hr_format == 0: + heart_rate = data[1] + else: + if len(data) < 3: + self.logger.debug(msg=f"Ignoring malformed HR packet: {data.hex()}") + return + heart_rate = int.from_bytes(bytes=data[1:3], byteorder="little") + + self.logger.debug(msg=f"Heart rate: {heart_rate} bpm") + + if self.callback: + self.callback(heart_rate) diff --git a/app/kayakfirst_ergometer_bluetooth.py b/app/kayakfirst_ergometer_bluetooth.py new file mode 100644 index 0000000..90eb5aa --- /dev/null +++ b/app/kayakfirst_ergometer_bluetooth.py @@ -0,0 +1,370 @@ +"""KayakFirst Bull ergometer BLE handler. + +Built on ``BleDevice`` (retrying connect, auto-reconnect, graceful +disconnect): sends the reset / handshake / display-config initialization +sequence, polls for data once per second, and reassembles the +semicolon-separated notification stream into complete lines — several lines +per packet and a line split across packets are both handled, with an overflow +guard and a buffer clear on every reconnect. Parsed rows (distance, speed, +pace, cadence, pull force, …) are delivered through a callback at 1 Hz. +Parsing is defensive: a garbled field drops one line rather than raising +inside the BLE notification callback. A mid-session reconnect deliberately +does not re-run ``initialize()``, which would reset the in-progress workout. + +Compatible with KayakFirst Bull (Blue) ergometer firmware. +""" + +import asyncio +from collections.abc import Callable +from datetime import datetime, timedelta +from decimal import Decimal, InvalidOperation +from typing import Any + +from .ble_device import BleDevice +from .events import StatusPayload +from .field_mapping import COLUMN_TYPES, FIELD_MAP + +KAYAK_FIRST_CHARACTERISTIC = "0000ffe1-0000-1000-8000-00805f9b34fb" + +RESET_CMD = "1\r\n" +HANDSHAKE_CMD = "21" +DISPLAY_CONFIG_CMD = "5" +POLL_DATA_CMD = "6\r\n" +START_CMD = "9;1\r\n" +STOP_CMD = "9;3\r\n" +POLL_INTERVAL_S = 1.0 + + +class ErgometerConnectionError(Exception): + """Raised when ergometer connection fails.""" + + +class KayakFirstErgometer(BleDevice): + """KayakFirst Bull ergometer handler.""" + + NOTIFY_UUID = KAYAK_FIRST_CHARACTERISTIC + device_key = "ergometer" + display_name = "Ergometer" + + def __init__( + self, + address: str, + person_weight: int = 75, + boat_weight: int = 12, + display_config: list[int] | None = None, + log_level: str = "INFO", + connect_timeout: float = 15.0, + connect_retries: int = 3, + auto_reconnect: bool = True, + reconnect_delay: float = 5.0, + status_callback: Callable[[StatusPayload], None] | None = None, + ) -> None: + """Initialize ergometer handler. + + Args: + address: Bluetooth device address. + person_weight: Person weight in kg. + boat_weight: Boat weight in kg. + display_config: Display configuration slots. + log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). + connect_timeout: Per-attempt BLE connection timeout in seconds. + connect_retries: Number of connection attempts before giving up. + auto_reconnect: Reconnect automatically if the link drops mid-session. + reconnect_delay: Delay between reconnect cycles in seconds. + status_callback: Optional callback receiving connection status events + (dicts with ``event`` and ``device`` keys). + """ + super().__init__( + address=address, + log_level=log_level, + connect_timeout=connect_timeout, + connect_retries=connect_retries, + auto_reconnect=auto_reconnect, + reconnect_delay=reconnect_delay, + status_callback=status_callback, + ) + self.person_weight = person_weight + self.boat_weight = boat_weight + self.display_config = ( + display_config if display_config is not None else [0, 2, 4, 14, 17] + ) + self.packet_buffer = bytearray() + self._consecutive_malformed_packets = 0 + + async def connect(self, callback: Callable[[dict[str, Any]], None]) -> None: + """Connect to ergometer. + + Args: + callback: Function to call with data updates (dict). + + Raises: + ErgometerConnectionError: If connection fails after all retries. + """ + try: + await super().connect(callback=callback) + except Exception as e: + self.logger.error( + msg=f"Failed to connect to ergometer at {self.address}: {e}" + ) + raise ErgometerConnectionError(str(e)) from e + + def _prepare_link(self) -> None: + """Reset the packet buffer before a reconnect. + + This prevents a reconnect from ever parsing a half-received packet + straddling the disconnect. + Reconnection re-opens the link and re-subscribes to notifications only; + it deliberately does not re-run ``initialize()``, which would reset the + in-progress workout. + """ + self.packet_buffer.clear() + + def _on_notify(self, sender: Any, data: bytearray) -> None: + r"""Handle incoming data packets from ergometer. + + Data format: '6;field1;field2;...;fieldN\r\n' + + Args: + sender: Characteristic handle that sent the notification. + data: Raw data packet. + """ + self.packet_buffer.extend(data) + + # Guard against unbounded growth if a terminator never arrives (garbled + # stream / wrong characteristic). + if len(self.packet_buffer) > 4096: + self.logger.debug(msg="Packet buffer overflow without terminator; clearing") + self.packet_buffer.clear() + return + + # A single notification may carry several complete packets (each ending + # in CRLF) and/or a trailing partial one. Process every complete line and + # keep the remainder buffered for the next notification. + while b"\r\n" in self.packet_buffer: + line, _, rest = self.packet_buffer.partition(b"\r\n") + self.packet_buffer = bytearray(rest) + + try: + packet_str = line.decode(encoding="utf-8").strip() + except UnicodeDecodeError as e: + self.logger.debug(msg=f"Error decoding packet: {e}") + continue + + if packet_str.startswith("6"): + parsed_data = self._parse_data_packet(packet=packet_str) + if parsed_data is None: + self._consecutive_malformed_packets += 1 + if self._consecutive_malformed_packets == 5: + self.logger.warning( + msg="Five consecutive malformed ergometer packets" + ) + self._emit_status( + event="malformed_data", + device="ergometer", + count=self._consecutive_malformed_packets, + ) + continue + self._consecutive_malformed_packets = 0 + if self.callback: + self.callback(parsed_data) + + @staticmethod + def _display_config_list_to_str(lst: list[int]) -> str: + """Convert display config list to semicolon-separated string. + + Args: + lst: List of display configuration values. + + Returns: + Semicolon-separated string of values. + + Raises: + ValueError: If list length is invalid or contains invalid values. + """ + allowed = {0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18} + + if not (1 <= len(lst) <= 5): + raise ValueError("List must contain between 1 and 5 items.") + + invalid = set(lst) - allowed + if invalid: + raise ValueError(f"Invalid values in list: {sorted(invalid)}") + + return ";".join(str(x) for x in lst) + + def _parse_data_packet(self, packet: str) -> dict[str, Any] | None: + """Parse one complete data packet into the exact v1 field schema. + + KayakFirst data format (semicolon-separated): + ``6;field1;field2;...;field23``. Packets with a different field count + or invalid load-bearing values are rejected. + + Args: + packet: Raw packet string. + + Returns: + Dictionary of all parsed values or None. + """ + self.logger.debug(msg=f"Raw packet: {packet}") + parts = packet.split(";") + + if len(parts) != 24 or parts[0] != "6": + return None + + data = {} + try: + parsed: list[int | Decimal] = [] + for i, part in enumerate(parts[:24]): + if i == 0: + continue + elif "." in part: + parsed.append(Decimal(value=part)) + else: + parsed.append(int(part)) + + data = { + FIELD_MAP[i]: value + for i, value in enumerate(iterable=parsed, start=1) + } + for field, value in data.items(): + expected = COLUMN_TYPES.get(field) + if expected == "int" and not isinstance(value, int): + return None + if expected == "decimal" and not isinstance(value, (int, Decimal)): + return None + if data["active_paddling"] not in (0, 1): + return None + if int(data["window_size__s"]) <= 0: + return None + if int(data["elapsed_time__s"]) < 0: + return None + if Decimal(data["distance__m"]) < 0: + return None + self.logger.debug(msg=f"Parsed data: {data}") + return data + except (ValueError, IndexError, InvalidOperation) as e: + # InvalidOperation: Decimal() rejects garbled fields like "12.3.4" + # and is NOT a ValueError — without it a corrupt packet would + # crash the BLE notification callback. + self.logger.debug(msg=f"Error parsing data values: {e}") + return None + + async def _send_command(self, command: str) -> None: + """Send command to ergometer. + + Commands are sent in chunks to respect MTU size. + + Args: + command: Command string to send. + """ + if not self.client or not self.client.is_connected: + raise ConnectionError("Cannot send command: ergometer is not connected") + + try: + command_bytes = command.encode(encoding="utf-8") + mtu_size = 20 + + for i in range(0, len(command_bytes), mtu_size): + chunk = command_bytes[i : i + mtu_size] + await self.client.write_gatt_char( + char_specifier=KAYAK_FIRST_CHARACTERISTIC, + data=chunk, + response=False, + ) + await asyncio.sleep(delay=0.05) + + self.logger.debug(msg=f"Sent command: {command.strip()}") + + except Exception as e: + self.logger.error(msg=f"Error sending command: {e}") + raise + + async def initialize(self) -> None: + """Initialize ergometer with handshake and configuration. + + Initialization sequence: + 1. Reset (twice) + 2. Handshake with time, timezone, person weight and boat weight + 3. Display configuration + """ + self.logger.info(msg="Initializing KayakFirst Bull...") + + self.logger.info(msg="Sending reset command...") + await self._send_command(command=RESET_CMD) + await asyncio.sleep(delay=2) + + self.logger.info(msg="Sending second reset...") + await self._send_command(command=RESET_CMD) + await asyncio.sleep(delay=3) + + now = datetime.now() + unix_time = int(now.timestamp()) + offset = now.astimezone().utcoffset() or timedelta(0) + tz_offset = int(offset.total_seconds() / 60) + + handshake = ( + f"{HANDSHAKE_CMD};{unix_time};{tz_offset};" + f"{self.person_weight};{self.boat_weight}\r\n" + ) + self.logger.info( + msg=f"Sending handshake (person: {self.person_weight}kg, boat: {self.boat_weight}kg)..." + ) + await self._send_command(command=handshake) + await asyncio.sleep(delay=3) + + display_config = ( + f"{DISPLAY_CONFIG_CMD};" + f"{self._display_config_list_to_str(lst=self.display_config)}\r\n" + ) + self.logger.info(msg="Sending display configuration...") + await self._send_command(command=display_config) + await asyncio.sleep(delay=3) + + self.logger.info(msg="Initialization complete") + + async def start_workout(self) -> None: + """Start workout session on ergometer.""" + self.logger.info(msg="Starting workout...") + await self._send_command(command=START_CMD) + await asyncio.sleep(delay=1) + + async def stop_workout(self) -> None: + """Stop workout session on ergometer.""" + self.logger.debug(msg="Stopping workout...") + await self._send_command(command=STOP_CMD) + # Reduced sleep for faster shutdown + await asyncio.sleep(delay=0.2) + + async def poll_data(self) -> None: + """Continuously poll ergometer for data. + + This should be run as a background task. Exits on its own once + ``disconnect()`` clears the run flag (it is normally also cancelled). + + Poll deadlines use the event loop's monotonic clock. Time spent writing + the command is therefore part of the one-second period instead of being + added to it. If a write takes longer than a complete period, resume one + period after it finishes rather than sending catch-up bursts. + """ + loop = asyncio.get_running_loop() + next_poll_at = loop.time() + while self._should_run: + delay = next_poll_at - loop.time() + if delay > 0: + await asyncio.sleep(delay) + if not self._should_run: + break + + if self.client and self.client.is_connected: + try: + await self._send_command(command=POLL_DATA_CMD) + except Exception as e: + # A single failed poll is recoverable; required lifecycle + # commands propagate, while the BLE reconnect/watchdog path + # owns transient streaming failures. + self.logger.warning(msg=f"Ergometer poll failed: {e}") + + next_poll_at += POLL_INTERVAL_S + now = loop.time() + if next_poll_at <= now: + next_poll_at = now + POLL_INTERVAL_S diff --git a/app/keep_awake.py b/app/keep_awake.py new file mode 100644 index 0000000..0ae7c15 --- /dev/null +++ b/app/keep_awake.py @@ -0,0 +1,120 @@ +"""Keep the display awake during a workout. + +During recording the athlete is on the ergometer, not touching the keyboard or +mouse, so the OS would normally dim and sleep the screen. This module holds a +platform-appropriate "stay awake" assertion for the duration of a workout and +releases it when the workout ends. + + - macOS: a `caffeinate` child process (tied to our PID so it can never + outlive the app). + - Windows: SetThreadExecutionState with DISPLAY + SYSTEM required. + - Linux: a best-effort `systemd-inhibit` child process; silently a no-op if + that tool is unavailable. + +Everything is best-effort: failing to keep the screen awake must never affect +data collection, so all errors are swallowed. +""" + +import contextlib +import os +import subprocess +import sys +from collections.abc import Callable + +# Windows SetThreadExecutionState flags. +_ES_CONTINUOUS = 0x80000000 +_ES_SYSTEM_REQUIRED = 0x00000001 +_ES_DISPLAY_REQUIRED = 0x00000002 + + +class ScreenAwake: + """Hold a display-stay-awake assertion between :meth:`start` and :meth:`stop`.""" + + def __init__(self, log: Callable[[str], None] | None = None) -> None: + self._log = log + self._active = False + self._proc: subprocess.Popen[bytes] | None = None + + def _debug(self, message: str) -> None: + if self._log: + with contextlib.suppress(Exception): + self._log(message) + + def start(self) -> None: + """Begin keeping the screen awake (idempotent).""" + if self._active: + return + try: + if sys.platform == "darwin": + # -d display, -i idle system, -s system (on AC); -w ties the + # helper's lifetime to ours so it cannot leak if we crash. + self._proc = subprocess.Popen( + ["caffeinate", "-d", "-i", "-s", "-w", str(os.getpid())], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self._active = True + elif sys.platform.startswith("win"): + import ctypes + + ctypes.windll.kernel32.SetThreadExecutionState( # type: ignore[attr-defined] + _ES_CONTINUOUS | _ES_SYSTEM_REQUIRED | _ES_DISPLAY_REQUIRED + ) + self._active = True + else: + # Linux / other: best-effort inhibitor; no-op if not present. + self._proc = subprocess.Popen( + [ + "systemd-inhibit", + "--what=idle:sleep", + "--who=KayakFit", + "--why=Workout in progress", + # Like caffeinate -w on macOS: the helper exits when we + # do, so a crash can never leak an eternal inhibitor. + "tail", + f"--pid={os.getpid()}", + "-f", + "/dev/null", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self._active = True + if self._active: + self._debug("Screen will stay awake during the workout.\n") + except FileNotFoundError: + # Helper binary not installed; nothing we can do, stay silent-ish. + self._active = False + self._proc = None + except Exception: + self._active = False + self._proc = None + + def stop(self) -> None: + """Release the stay-awake assertion (idempotent, safe to over-call).""" + if not self._active: + return + try: + if sys.platform.startswith("win"): + import ctypes + + ctypes.windll.kernel32.SetThreadExecutionState( # type: ignore[attr-defined] + _ES_CONTINUOUS + ) + elif self._proc is not None: + self._proc.terminate() + try: + self._proc.wait(timeout=2) + except Exception: + with contextlib.suppress(Exception): + self._proc.kill() + except Exception: + pass + finally: + self._proc = None + self._active = False + + @property + def active(self) -> bool: + """Return True while a display-sleep inhibitor is currently held.""" + return self._active diff --git a/app/logger.py b/app/logger.py new file mode 100644 index 0000000..37e4e26 --- /dev/null +++ b/app/logger.py @@ -0,0 +1,201 @@ +"""Centralized logging configuration (singleton ``Logger``). + +Wraps stdlib ``logging`` with one process-wide, thread-safe setup: a console +sink or — while the GUI runs — a ``CallbackHandler`` that streams formatted +lines into the on-screen activity log, plus a persistent rotating file handler +at ``~/KayakFit/logs/kayakfit.log`` that is kept open across re-setups +(closing/reopening would race concurrent writers). ``setup()`` may be called +repeatedly; it only reconfigures when a new ``log_callback`` is supplied or +``force=True``. + +Logging only: process signal handling (SIGINT/SIGTERM) is owned by the +entrypoint (see ``kayakfit_gui.main``), not by this module. +""" + +import contextlib +import logging +import sys +import threading +from collections.abc import Callable +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import IO + + +def _build_file_handler() -> logging.Handler | None: + """Create a rotating file handler under ~/KayakFit/logs (cross-platform). + + Returns None if the log directory cannot be created, so logging setup never + fails because of the file sink. + """ + try: + log_dir = Path.home() / "KayakFit" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + filename=str(log_dir / "kayakfit.log"), + maxBytes=1_000_000, + backupCount=5, + encoding="utf-8", + ) + handler.setFormatter( + logging.Formatter( + fmt="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + return handler + except Exception: + return None + + +class CallbackHandler(logging.Handler): + """Custom logging handler that sends messages to a callback function.""" + + def __init__(self, callback: Callable[[str], None]) -> None: + super().__init__() + self.callback = callback + + def emit(self, record: logging.LogRecord) -> None: + try: + msg = self.format(record) + self.callback(msg + "\n") + except Exception: + self.handleError(record) + + +class Logger: + """Centralized logger configuration for the application.""" + + _initialized = False + _log_level = logging.INFO + # Guards reconfiguration: setup()/set_level() run from worker threads + # (workout_session, strava_uploader attach the GUI callback off the main + # thread) while other threads emit records, so handler swaps must be atomic. + # Reentrant because get_logger() may call setup() while a caller already + # holds the lock. + _lock = threading.RLock() + # Single process-wide rotating file sink, created once and kept across + # re-setups so we don't close and reopen the same log file on every + # workout/export (which races with concurrent writes and drops buffered + # records). + _file_handler: logging.Handler | None = None + + @classmethod + def setup( + cls, + log_level: str = "INFO", + force: bool = False, + stream: IO[str] | None = None, + log_callback: Callable[[str], None] | None = None + ) -> None: + """Setup application-wide logging configuration. + + Args: + log_level: Log level as string (DEBUG, INFO, WARNING, ERROR, CRITICAL). + force: Force reconfiguration even if already initialized. + stream: Optional custom stream for log output (defaults to sys.stdout). + log_callback: Optional callback function for log messages. + """ + with cls._lock: + # Reconfigure when a GUI log_callback is supplied even if already + # initialized, so workout/export log lines reach the activity log + # regardless of which component initialized logging first (e.g. a + # prior CSV read that set up a stdout-only sink). + if cls._initialized and not force and log_callback is None: + return + + root_logger = logging.getLogger() + # Drop the console/callback handlers we manage, but keep the + # persistent rotating file sink open (see _file_handler). Closing it + # here would reopen the same file on every workout/export and could + # lose records buffered by another thread mid-write. + for handler in list(root_logger.handlers): + if handler is cls._file_handler: + continue + with contextlib.suppress(Exception): + handler.close() + root_logger.removeHandler(handler) + + # If log_callback is provided, use CallbackHandler + if log_callback: + callback_handler = CallbackHandler(callback=log_callback) + log_level_str = log_level.upper() + cls._log_level = getattr(logging, log_level_str, logging.INFO) + callback_handler.setLevel(level=cls._log_level) + + formatter = logging.Formatter(fmt="%(message)s") + callback_handler.setFormatter(fmt=formatter) + + root_logger.addHandler(hdlr=callback_handler) + else: + log_level_str = log_level.upper() + cls._log_level = getattr(logging, log_level_str, logging.INFO) + + # Use custom stream if provided, otherwise stdout (or stderr). + # In a windowed/frozen build (PyInstaller --noconsole on Windows) + # sys.stdout/sys.stderr can be None — skip the console sink then; + # the rotating file handler below still captures everything. + output_stream = stream if stream is not None else (sys.stdout or sys.stderr) + if output_stream is not None: + console_handler = logging.StreamHandler(stream=output_stream) + console_handler.setLevel(level=cls._log_level) + console_handler.setFormatter(fmt=logging.Formatter(fmt="%(message)s")) + root_logger.addHandler(hdlr=console_handler) + + # Only reconfigure stdout if we're using it and it has a + # reconfigure method. Fetched via getattr so this stays + # type-clean regardless of how the active typeshed types + # sys.stdout (TextIO vs TextIOWrapper) -- no `type: ignore` + # that could later be flagged unused. + reconfigure = getattr(sys.stdout, "reconfigure", None) + if stream is None and reconfigure is not None: + reconfigure(line_buffering=True) + + # Always keep a persistent rotating file sink so field issues (BLE + # drops, upload failures) can be diagnosed after the fact. Created + # once and reused; best-effort, so a failed creation is retried on a + # later setup(). + if cls._file_handler is None: + cls._file_handler = _build_file_handler() + if cls._file_handler is not None: + root_logger.addHandler(hdlr=cls._file_handler) + if cls._file_handler is not None: + cls._file_handler.setLevel(level=cls._log_level) + + root_logger.setLevel(level=cls._log_level) + + cls._initialized = True + + @classmethod + def get_logger(cls, name: str | None = None) -> logging.Logger: + """Get a logger instance. + + Args: + name: Logger name (typically __name__ of the calling module). + + Returns: + Configured logger instance. + """ + if not cls._initialized: + cls.setup() + + return logging.getLogger(name=name) + + @classmethod + def set_level(cls, log_level: str) -> None: + """Change the log level after initialization. + + Args: + log_level: New log level as string (DEBUG, INFO, WARNING, ERROR, CRITICAL). + """ + log_level_str = log_level.upper() + new_level = getattr(logging, log_level_str, logging.INFO) + + with cls._lock: + cls._log_level = new_level + + root_logger = logging.getLogger() + root_logger.setLevel(level=new_level) + for handler in list(root_logger.handlers): + handler.setLevel(level=new_level) + diff --git a/app/power.py b/app/power.py new file mode 100644 index 0000000..e36f5de --- /dev/null +++ b/app/power.py @@ -0,0 +1,57 @@ +"""Shared power estimation. + +The KayakFirst ergometer has no power sensor; its display derives power from +flywheel-based pull force and stroke rate. This module reproduces that model: + + power (W) = pull_force (N) x pull_length (m) x strokes_per_second + +Physics: work per stroke = force x pull distance (J); strokes per second = +cadence / 60; power = J/s = W. KayakFit applies the model to the ergometer's +instantaneous pull-force and cadence points. The calibrated effective handle +pull length is L = 0.600 m. + +The pull length can be recalibrated for other KayakFirst models or firmware +revisions (see docs/POWER_MODEL.md); callers pass the recorded +``pull_length_m`` value explicitly so concurrent exports cannot affect each +other through process-global state. + +This single formula and instantaneous-source policy are used by the live +dashboard, summary window, and FIT exporter. +""" + +from typing import Any + +# Effective handle pull length (m). See module docstring for the calibration. +DEFAULT_PULL_LENGTH_M = 0.600 + +def normalize_pull_length(value: Any) -> float: + """Return a valid effective pull length, or the calibrated default.""" + try: + length = float(value) + except (TypeError, ValueError): + length = DEFAULT_PULL_LENGTH_M + # Sanity range: a handle pull length outside this is a config mistake. + if not 0.3 <= length <= 1.2: + length = DEFAULT_PULL_LENGTH_M + return length + + +def estimate_power( + pull_force_n: Any, + cadence_spm: Any, + pull_length_m: Any = DEFAULT_PULL_LENGTH_M, +) -> int | None: + """Estimate power (W) from pull force (N) and cadence (spm). + + Callers supply the instantaneous pull-force and cadence fields retained + from the same ergometer packet. + + Returns None when either input is missing or non-numeric. + """ + if pull_force_n is None or cadence_spm is None: + return None + try: + length = normalize_pull_length(pull_length_m) + return round(float(pull_force_n) * length * float(cadence_spm) / 60) + except (TypeError, ValueError): + return None diff --git a/app/program.py b/app/program.py new file mode 100644 index 0000000..adb8195 --- /dev/null +++ b/app/program.py @@ -0,0 +1,394 @@ +"""Structured training programs. + +A program is an ordered list of steps; steps may be grouped into repeated +blocks. This module defines the data model, flattens a program specification +(with ``repeat`` blocks) into a flat step list, and loads programs from +``~/KayakFit/programs/*.json``. Bundled example programs are copied into that +folder once on first run (``install_examples``) so users can edit, rename, or +delete them like any other plan. + +The model is GUI- and BLE-free so it can be unit-tested and reused anywhere. +""" + +import json +import shutil +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .logger import Logger + +logger = Logger.get_logger(name=__name__) + +# Step completion rules. +TIME = "time" +DISTANCE = "distance" +OPEN = "open" # advance manually (lap / stop button) + +# Step kinds the GUI knows how to colour and sound-cue. +KNOWN_KINDS = frozenset({"warmup", "work", "effort", "rest", "cooldown"}) +KNOWN_TARGET_METRICS = frozenset( + {"hr_zone", "pace_200", "pace_500", "pace_1000", "power", "spm"} +) + +# Safety ceiling on a single flattened program. A real interval plan has at +# most a few hundred steps; a few thousand is generous headroom. Enforced +# *during* flattening so a runaway (e.g. deeply nested "repeat") aborts early +# instead of exhausting memory first. +MAX_STEPS = 5000 + +# Hard cap on the total amount of expansion work, independent of how many +# steps actually get appended. Nested "repeat" blocks expand as 2^N even when +# the innermost block is empty (so MAX_STEPS alone would never trip), which can +# hang the app; this bounds the work regardless. +_MAX_FLATTEN_OPS = 200_000 + +# Guards against pathologically deep "repeat" nesting turning into a Python +# RecursionError; real plans nest at most a handful of levels. +_MAX_REPEAT_DEPTH = 200 + + +class ProgramError(ValueError): + """A program specification is structurally invalid or unsafe to load.""" + + +@dataclass +class Step: + """A single program step.""" + + label: str + kind: str # warmup | work | rest | cooldown | effort + duration_kind: str # TIME | DISTANCE | OPEN + duration_value: float | None = None # seconds or metres; None if OPEN + target: dict[str, Any] | None = None # {"metric","low","high"} (optional) + + def is_open(self) -> bool: + """Return True if this step has no fixed time/distance boundary.""" + return self.duration_kind == OPEN or self.duration_value is None + + +@dataclass +class Program: + """A named, flattened sequence of steps.""" + + identifier: str + name: str + steps: list[Step] = field(default_factory=list) + + def __len__(self) -> int: + return len(self.steps) + + +def _fmt_time(seconds: float) -> str: + total = int(seconds) + m, s = divmod(total, 60) + return f"{m}:{s:02d}" + + +def _default_label(kind: str, duration_kind: str, value: float | None) -> str: + name = {"warmup": "Warm-up", "work": "Work", "rest": "Rest", + "cooldown": "Cool-down"}.get(kind, kind.capitalize()) + if duration_kind == TIME and value is not None: + return f"{name} {_fmt_time(value)}" + if duration_kind == DISTANCE and value is not None: + return f"{name} {int(value)} m" + return name + + +def _validate_duration_value(raw: Any, field_name: str) -> float: + """Coerce a ``seconds``/``meters`` value to a sane positive float. + + Raises ``ProgramError`` (never lets an unvalidated value reach label + generation) for missing, non-numeric, non-finite, or non-positive values — + e.g. ``"seconds": -30``, ``0``, ``"abc"`` or ``[30]``. + """ + if raw is None: + raise ProgramError(f"'{field_name}' is required for this step") + # bool is an int subclass; True/False is never a real duration. + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + raise ProgramError(f"'{field_name}' must be a number, got {raw!r}") + value = float(raw) + if value != value or value in (float("inf"), float("-inf")): + raise ProgramError(f"'{field_name}' must be a finite number, got {raw!r}") + if value <= 0: + raise ProgramError(f"'{field_name}' must be positive, got {raw!r}") + return value + + +def _make_step(item: dict[str, Any]) -> Step: + allowed = {"type", "label", "duration", "target"} + unknown = sorted(set(item) - allowed) + if unknown: + raise ProgramError(f"unsupported step fields: {unknown}") + duration = item.get("duration") + if not isinstance(duration, dict): + raise ProgramError("step 'duration' must be an object") + kind = duration.get("kind") + if kind == TIME: + if set(duration) != {"kind", "seconds"}: + raise ProgramError("time duration requires only 'kind' and 'seconds'") + value: float | None = _validate_duration_value(duration.get("seconds"), "seconds") + elif kind == DISTANCE: + if set(duration) != {"kind", "meters"}: + raise ProgramError("distance duration requires only 'kind' and 'meters'") + value = _validate_duration_value(duration.get("meters"), "meters") + elif kind == OPEN: + if set(duration) != {"kind"}: + raise ProgramError("open duration accepts only the 'kind' field") + value = None + else: + raise ProgramError(f"unsupported duration kind {kind!r}") + step_type = item.get("type") + if step_type not in KNOWN_KINDS: + raise ProgramError( + f"unsupported step type {step_type!r}; expected one of " + f"{sorted(KNOWN_KINDS)}" + ) + raw_label = item.get("label") + if raw_label is not None and (not isinstance(raw_label, str) or not raw_label.strip()): + raise ProgramError("step 'label' must be non-empty text") + label = raw_label.strip() if isinstance(raw_label, str) else _default_label( + step_type, kind, value + ) + target = _validate_target(item.get("target")) + return Step( + label=str(label), + kind=step_type, + duration_kind=kind, + duration_value=value, + target=target, + ) + + +def _validate_target(raw: Any) -> dict[str, Any] | None: + """Validate an optional live target before GUI code receives it.""" + if raw is None: + return None + if not isinstance(raw, dict): + raise ProgramError("step 'target' must be an object") + if set(raw) != {"metric", "low", "high"}: + raise ProgramError("target requires exactly 'metric', 'low', and 'high'") + metric = raw.get("metric") + if metric not in KNOWN_TARGET_METRICS: + raise ProgramError(f"unsupported target metric {metric!r}") + low = raw.get("low") + high = raw.get("high") + if ( + isinstance(low, bool) + or isinstance(high, bool) + or not isinstance(low, (int, float)) + or not isinstance(high, (int, float)) + ): + raise ProgramError("target 'low' and 'high' must be numbers") + low_f, high_f = float(low), float(high) + if not all(v == v and v not in (float("inf"), float("-inf")) for v in (low_f, high_f)): + raise ProgramError("target bounds must be finite") + if low_f > high_f: + raise ProgramError("target 'low' must not exceed 'high'") + if metric == "hr_zone" and (low_f < 1 or high_f > 5): + raise ProgramError("heart-rate zone targets must be within Z1..Z5") + return {"metric": metric, "low": low, "high": high} + + +def _flatten( + items: Any, + out: list[Step], + budget: list[int] | None = None, + depth: int = 0, +) -> None: + """Expand ``repeat`` blocks into ``out``, validating structure as we go. + + ``budget`` is a single-element mutable counter of expansion operations, + bounding total work (see ``_MAX_FLATTEN_OPS``) so a runaway/exponential + nested ``repeat`` aborts early instead of hanging or OOM-ing. Structural + problems (``steps`` not a list, a step that isn't an object, a bad + ``repeat`` count) raise ``ProgramError`` locally and predictably rather + than surfacing as whatever incidental ``AttributeError`` the first ``.get`` + happens to throw. + """ + if budget is None: + budget = [0] + if depth > _MAX_REPEAT_DEPTH: + raise ProgramError(f"'repeat' nesting exceeds {_MAX_REPEAT_DEPTH} levels") + if not isinstance(items, list): + raise ProgramError(f"'steps' must be a list, not {type(items).__name__}") + for item in items: + budget[0] += 1 + if budget[0] > _MAX_FLATTEN_OPS: + raise ProgramError( + "program is too large to expand " + f"(over {_MAX_FLATTEN_OPS} operations — runaway 'repeat'?)" + ) + if not isinstance(item, dict): + raise ProgramError(f"each step must be an object, not {type(item).__name__}") + if "repeat" in item: + if set(item) != {"repeat", "steps"}: + raise ProgramError("repeat blocks require exactly 'repeat' and 'steps'") + raw = item["repeat"] + if isinstance(raw, bool) or not isinstance(raw, int) or raw <= 0: + raise ProgramError(f"'repeat' must be a positive whole number, got {raw!r}") + count = raw + nested = item["steps"] + for _ in range(count): + _flatten(nested, out, budget, depth + 1) + else: + if len(out) >= MAX_STEPS: + raise ProgramError(f"program exceeds the {MAX_STEPS}-step limit") + out.append(_make_step(item)) + + +def load_program(spec: dict[str, Any], source: str | None = None) -> Program: + """Build a Program from a specification dict (flattening repeat blocks). + + ``source`` supplies the stable file identifier when loading from disk. + Raises ``ProgramError`` for a structurally invalid or unsafe spec. + """ + if not isinstance(spec, dict): + raise ProgramError(f"program spec must be an object, not {type(spec).__name__}") + if set(spec) != {"name", "steps"}: + raise ProgramError("program requires exactly 'name' and 'steps'") + name = spec.get("name") + if not isinstance(name, str) or not name.strip(): + raise ProgramError("program 'name' must be non-empty text") + steps: list[Step] = [] + _flatten(spec.get("steps", []), steps, budget=[0], depth=0) + return Program(identifier=source or name, name=name.strip(), steps=steps) + + +# --------------------------------------------------------------------------- # +# Program directories # +# --------------------------------------------------------------------------- # +def _presets_dir() -> Path: + """Folder holding the built-in preset programs (bundled JSON). + + Uses the PyInstaller extraction dir when frozen, otherwise the repo's + ``presets/`` folder next to the ``app`` package when running from source. + """ + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + return Path(sys._MEIPASS) / "presets" + return Path(__file__).resolve().parent.parent / "presets" + + +def programs_dir() -> Path: + """Folder holding the user's programs (examples are installed here too).""" + return Path.home() / "KayakFit" / "programs" + + +# Marker written after the one-time example install, so examples the user +# deletes or edits are never re-copied on later launches. +_EXAMPLES_MARKER = ".examples-installed" + + +def install_examples(dest: Path | None = None) -> int: + """Copy the bundled example programs into the user's programs folder. + + Runs once (guarded by a marker file in the destination). Existing files + are never overwritten. Best-effort: returns the number of files copied, + 0 if already installed or on error. + """ + target = dest if dest is not None else programs_dir() + marker = target / _EXAMPLES_MARKER + try: + if marker.exists(): + return 0 + target.mkdir(parents=True, exist_ok=True) + copied = 0 + for src in sorted(_presets_dir().glob("*.json")): + dst = target / src.name + if not dst.exists(): + shutil.copyfile(src, dst) + copied += 1 + marker.write_text("Bundled example programs were installed here.\n", + encoding="utf-8") + return copied + except OSError: + return 0 + + +def _record_skip(warnings: list[str] | None, path: Path, reason: str) -> None: + """Log (and optionally collect) a per-file load failure. + + Failures are logged at WARNING; the collected ``warnings`` list lets a + caller (the GUI) surface a "N plan(s) skipped" summary without re-scanning. + """ + logger.warning("Program %s skipped: %s", path.name, reason) + if warnings is not None: + warnings.append(f"{path.name}: {reason}") + + +def _load_programs_from( + directory: Path, warnings: list[str] | None = None +) -> list[Program]: + """Load every valid ``*.json`` program in a directory (best-effort, sorted). + + A single malformed file never takes down the rest: every failure mode + (unreadable/undecodable file, invalid JSON, BOM/non-UTF-8, structurally + malformed spec, negative/non-numeric durations, runaway ``repeat``) is + caught, logged with a reason, and skipped individually. A file with no + steps to run is distinguished from an actual failure: it is logged at INFO + (not WARNING) and simply not added, so "nothing to run" is never conflated + with "failed to load". + """ + out: list[Program] = [] + if not directory.exists(): + return out + for path in sorted(directory.glob("*.json")): + # NOTE: encoding="utf-8" (not utf-8-sig) is deliberate — a UTF-8 BOM + # (common from Notepad) makes json.load raise, so such a file is + # skipped *and logged* rather than silently vanishing. We favour a + # discoverable skip over silently accepting a mis-encoded file. + try: + with open(path, encoding="utf-8") as f: + spec = json.load(f) + except (OSError, ValueError) as exc: + # ValueError covers json.JSONDecodeError and UnicodeDecodeError. + _record_skip(warnings, path, f"could not be read ({exc})") + continue + + if not isinstance(spec, dict): + _record_skip(warnings, path, "top-level JSON is not an object") + continue + + try: + prog = load_program(spec, source=path.name) + except ProgramError as exc: + _record_skip(warnings, path, str(exc)) + continue + except (OSError, ValueError, TypeError, AttributeError, KeyError, + IndexError, RecursionError, MemoryError) as exc: + # Defence in depth: any unforeseen malformed-structure error (or a + # RecursionError/MemoryError from a runaway file) must skip only + # this one file, never propagate and wipe the whole program list. + _record_skip(warnings, path, f"failed to load ({type(exc).__name__}: {exc})") + continue + + if not prog.steps: + logger.info( + "Program %s has no steps; skipping (nothing to run).", path.name + ) + continue + out.append(prog) + return out + + +def builtin_programs(warnings: list[str] | None = None) -> list[Program]: + """Bundled example programs (the install source for ``install_examples``).""" + return _load_programs_from(_presets_dir(), warnings) + + +def user_programs(warnings: list[str] | None = None) -> list[Program]: + """Load programs from ~/KayakFit/programs/*.json (best-effort).""" + return _load_programs_from(programs_dir(), warnings) + + +def list_programs(warnings: list[str] | None = None) -> list[Program]: + """All available programs. + + Everything lives in ``~/KayakFit/programs/`` — the bundled examples are + copied there once (``install_examples``), alongside user-authored plans. + + Pass a list as ``warnings`` to collect one human-readable string per + skipped file; the same reasons are always logged. + """ + return user_programs(warnings) diff --git a/app/program_runner.py b/app/program_runner.py new file mode 100644 index 0000000..c9a6313 --- /dev/null +++ b/app/program_runner.py @@ -0,0 +1,279 @@ +"""Online execution engine for a structured training program. + +The runner consumes workout elapsed time and cumulative distance samples. A +single delayed sample can cross several step boundaries; every boundary is +preserved in ``state["transitions"]`` and delivered to ``on_event`` in order. +Each transition includes the exact interpolated elapsed/distance coordinates so +the recording layer can persist a precise performed-step timeline. +""" + +from collections.abc import Callable +from math import isfinite +from typing import Any + +from .coerce import to_float +from .program import DISTANCE, OPEN, TIME, Program, Step + + +class ProgramRunner: + """Advance a flattened program from live elapsed-time/distance samples.""" + + def __init__( + self, + program: Program, + on_event: Callable[[dict[str, Any]], None] | None = None, + ) -> None: + self.program = program + self.steps: list[Step] = list(program.steps) + self.on_event = on_event + self.reset() + + def reset(self) -> None: + """Rewind to the first step and clear all elapsed/distance progress.""" + self.index = 0 + self.done = len(self.steps) == 0 + self._started = False + self._step_start_elapsed = 0.0 + self._step_start_distance = 0.0 + self._elapsed = 0.0 + self._distance = 0.0 + self._previous_elapsed = 0.0 + self._last_raw_distance: float | None = None + + @property + def current(self) -> Step | None: + """Return the active step, or None when the program is finished.""" + if self.done or self.index >= len(self.steps): + return None + return self.steps[self.index] + + def _next_step(self) -> Step | None: + nxt = self.index + 1 + return self.steps[nxt] if nxt < len(self.steps) else None + + def _remaining(self) -> dict[str, Any]: + step = self.current + if step is None: + return {"remaining": None, "remaining_kind": None} + if step.duration_kind == TIME: + used = self._elapsed - self._step_start_elapsed + return { + "remaining": max((step.duration_value or 0.0) - used, 0.0), + "remaining_kind": TIME, + } + if step.duration_kind == DISTANCE: + used = self._distance - self._step_start_distance + return { + "remaining": max((step.duration_value or 0.0) - used, 0.0), + "remaining_kind": DISTANCE, + } + return {"remaining": None, "remaining_kind": OPEN} + + def _fraction(self) -> float | None: + """Return progress through the current step in the range 0..1.""" + step = self.current + if step is None or step.is_open() or not step.duration_value: + return None + if step.duration_kind == TIME: + used = self._elapsed - self._step_start_elapsed + else: + used = self._distance - self._step_start_distance + return max(0.0, min(used / step.duration_value, 1.0)) + + def _state( + self, + transition: str | None, + *, + boundary_elapsed: float | None = None, + boundary_distance: float | None = None, + initial: bool = False, + ) -> dict[str, Any]: + return { + "transition": transition, + "step_index": self.index, + "total": len(self.steps), + "step": self.current, + "next": self._next_step(), + "done": self.done, + "fraction": self._fraction(), + "boundary_elapsed": boundary_elapsed, + "boundary_distance": boundary_distance, + "initial": initial, + **self._remaining(), + } + + def _result(self, transitions: list[dict[str, Any]]) -> dict[str, Any]: + """Return current state plus transitions crossed by one action.""" + state = dict(transitions[-1]) if transitions else self._state(None) + state["transitions"] = transitions + return state + + def _emit_transition( + self, + transition: str, + boundary_elapsed: float, + boundary_distance: float, + *, + initial: bool = False, + ) -> dict[str, Any]: + """Build and notify one exact transition state.""" + state = self._state( + transition, + boundary_elapsed=boundary_elapsed, + boundary_distance=boundary_distance, + initial=initial, + ) + if self.on_event is not None: + self.on_event(state) + return state + + def _begin_step(self, elapsed: float, distance: float) -> None: + self._step_start_elapsed = elapsed + self._step_start_distance = distance + + @staticmethod + def _interpolate( + target: float, + start_x: float, + end_x: float, + start_y: float, + end_y: float, + ) -> float: + """Linearly interpolate y where a sample crosses ``target`` on x.""" + if end_x <= start_x: + return end_y + fraction = max(0.0, min((target - start_x) / (end_x - start_x), 1.0)) + return start_y + fraction * (end_y - start_y) + + def _completion_boundary( + self, + sample_start_elapsed: float, + sample_start_distance: float, + ) -> tuple[float, float] | None: + """Return exact elapsed/distance coordinates where the step ends.""" + step = self.current + if step is None or step.is_open(): + return None + if step.duration_kind == TIME: + boundary_elapsed = self._step_start_elapsed + (step.duration_value or 0.0) + if self._elapsed < boundary_elapsed: + return None + boundary_distance = self._interpolate( + boundary_elapsed, + sample_start_elapsed, + self._elapsed, + sample_start_distance, + self._distance, + ) + return boundary_elapsed, boundary_distance + if step.duration_kind == DISTANCE: + boundary_distance = self._step_start_distance + (step.duration_value or 0.0) + if self._distance < boundary_distance: + return None + boundary_elapsed = self._interpolate( + boundary_distance, + sample_start_distance, + self._distance, + sample_start_elapsed, + self._elapsed, + ) + return boundary_elapsed, boundary_distance + return None + + def update( + self, + elapsed_s: Any = None, + distance_m: Any = None, + sample_duration_s: Any = None, + ) -> dict[str, Any]: + """Feed one sample and return current state plus crossed transitions.""" + if self.done: + return self._result([]) + + current_elapsed = to_float(elapsed_s) + represented_s = to_float(sample_duration_s) + if ( + current_elapsed is None + or not isfinite(current_elapsed) + or represented_s is None + or not isfinite(represented_s) + or represented_s < 0 + ): + raise ValueError("ProgramRunner requires canonical non-overlapping timing") + # Validate the monotonic, non-overlapping invariant in integer + # milliseconds, the same representation RecordingTimeline and + # finalize_workout use. The canonical fields are millisecond-derived, so + # comparing the float seconds directly makes an exactly-filled window + # (represented == available) fail spuriously: e.g. a real 2.251 s + # endpoint after 2.0 s yields 2.251 - 2.0 == 0.2509999999999999, just + # under a legitimate 0.251 s window. Rounding both sides first removes + # that false positive while still rejecting a genuine overlap. + endpoint_ms = round(current_elapsed * 1000) + previous_ms = round(self._previous_elapsed * 1000) + represented_ms = round(represented_s * 1000) + if endpoint_ms < previous_ms or represented_ms > endpoint_ms - previous_ms: + raise ValueError("ProgramRunner requires canonical non-overlapping timing") + + sample_start_elapsed = current_elapsed - represented_s + sample_start_distance = self._distance + self._elapsed = current_elapsed + raw_distance = to_float(distance_m) + if raw_distance is not None and isfinite(raw_distance) and raw_distance >= 0: + if self._last_raw_distance is None: + self._distance += raw_distance + else: + self._distance += max(raw_distance - self._last_raw_distance, 0.0) + self._last_raw_distance = raw_distance + transitions: list[dict[str, Any]] = [] + + if not self._started: + self._started = True + self._begin_step(0.0, 0.0) + transitions.append( + self._emit_transition( + "step_start", + 0.0, + 0.0, + initial=True, + ) + ) + # One delayed sample may cross several short steps. Carry both time and + # distance overshoot across every exact boundary in this sample, + # including the first represented sensor window. + for _ in range(len(self.steps)): + boundary = self._completion_boundary( + sample_start_elapsed, sample_start_distance + ) + if boundary is None: + break + boundary_elapsed, boundary_distance = boundary + transition = self._finish_current( + boundary_elapsed, boundary_distance + ) + transitions.append( + self._emit_transition( + transition, boundary_elapsed, boundary_distance + ) + ) + if self.done: + break + + self._previous_elapsed = self._elapsed + return self._result(transitions) + + def advance(self) -> dict[str, Any]: + """Manually finish the current step (for OPEN steps or skipping).""" + if self.done: + return self._result([]) + transition = self._finish_current(self._elapsed, self._distance) + event = self._emit_transition(transition, self._elapsed, self._distance) + return self._result([event]) + + def _finish_current(self, boundary_elapsed: float, boundary_distance: float) -> str: + """Move to the next step or finish the program.""" + if self.index + 1 < len(self.steps): + self.index += 1 + self._begin_step(boundary_elapsed, boundary_distance) + return "step_start" + self.done = True + return "program_complete" diff --git a/app/read_csv.py b/app/read_csv.py new file mode 100644 index 0000000..ca28910 --- /dev/null +++ b/app/read_csv.py @@ -0,0 +1,173 @@ +"""Workout CSV reading. + +Parses the semicolon-separated workout CSV into a Table, converting each cell +to its schema type (see ``app.field_mapping``) and applying the fixed v1 metric +policy. The v1 header must match the writer +schema exactly. Recovery is local to malformed data rows (for example a +crash-truncated final line); schema drift and majority-corrupt files raise +``CsvReadError``. +""" + +import csv +from pathlib import Path +from typing import Any + +from .field_mapping import COLUMN_TYPES, CSV_COLUMNS +from .logger import Logger +from .table import Table, parse_value + +# If more than this fraction of the well-formed data rows fail type conversion, +# the file is treated as fundamentally corrupt (wrong/garbled file, mangled +# encoding, not our data) and rejected outright, rather than silently returning +# whatever fragment happened to parse. A handful of bad cells in an otherwise +# good multi-hour recording is recoverable and expected; a majority-bad file is +# not a recording worth recovering. Mirrors the "is this normal or +# catastrophic" judgement made for runaway programs in app/program.py. +_MAX_CORRUPT_ROW_FRACTION = 0.5 + + +class CsvReadError(Exception): + """Raised when a workout CSV cannot be read or fails validation.""" + + +class CsvReader: + """Read workout data from CSV files with semicolon separator.""" + + def __init__(self, file_path: str) -> None: + """Initialize reader with path to CSV file. + + Args: + file_path: Path to the CSV file to read. + + Raises: + FileNotFoundError: If CSV file does not exist. + """ + self.file_path = Path(file_path) + if not self.file_path.exists(): + raise FileNotFoundError(f"CSV file not found: {file_path}") + + self.logger = Logger.get_logger(name=__name__) + + + def read_all(self) -> Table: + """Read all records from the CSV file using canonical metric channels. + + Returns: + Table of workout data records. ``session_elapsed`` and + ``sample_duration`` are the only processing clock. Raw device time + is retained as ``device_elapsed`` / ``device_window`` for audit. + """ + self.logger.info(msg=f"Read records from {self.file_path.name}.") + + # Preserve the validated recording timeline exactly. Moving/stationary + # classification belongs to app.segmentation; deleting low-speed rows + # here used to erase short pauses and turn longer ones into timestamp + # gaps. Likewise, payload-level de-duplication could remove legitimate + # samples from a later interval merely because every metric happened to + # repeat. Transport duplicates, if the protocol proves they exist, must + # be identified locally and adjacently during capture rather than by a + # global value-based pass over the canonical recording. + + key_map: dict[str, str] = { + "timestamp": "timestamp", + "session_elapsed__s": "session_elapsed", + "sample_duration__s": "sample_duration", + "elapsed_time__s": "device_elapsed", + "window_size__s": "device_window", + "kayakfirst_timestamp": "kayakfirst_timestamp", + "heart_rate__bpm": "heart_rate", + "cadence_instant__spm": "cadence_instant", + "distance__m": "distance", + "speed_instant__mps": "speed_instant", + "pull_force_instant__n": "pull_force_instant", + "active_paddling": "active", + } + + out_columns = [ + "timestamp", "session_elapsed", "sample_duration", + "device_elapsed", "device_window", "kayakfirst_timestamp", + "heart_rate", "cadence_instant", "distance", "speed_instant", + "pull_force_instant", "active", + ] + out_rows = self._read_rows(key_map) + return Table(out_columns, out_rows) + + def _read_rows( + self, key_map: dict[str, str] + ) -> list[dict[str, Any]]: + """Stream the CSV into the reduced typed rows used by processing.""" + try: + with open(self.file_path, encoding="utf-8", newline="") as f: + reader = csv.reader(f, delimiter=";") + try: + raw_header = next(reader) + except StopIteration as exc: + raise CsvReadError("CSV file is empty or contains no data.") from exc + + header = [h.strip() for h in raw_header] + if header != CSV_COLUMNS: + missing = sorted(set(CSV_COLUMNS) - set(header)) + extra = sorted(set(header) - set(CSV_COLUMNS)) + order_error = not missing and not extra + raise CsvReadError( + "Workout is not v1: CSV schema mismatch " + f"(missing={missing}, extra={extra}, order_error={order_error})" + ) + + parsed: list[dict[str, Any]] = [] + length_skipped = 0 + corrupt_skipped = 0 + data_row_count = 0 + for line_no, raw in enumerate(reader, start=2): + if not raw or (len(raw) == 1 and raw[0].strip() == ""): + continue + if len(raw) != len(header): + length_skipped += 1 + continue + data_row_count += 1 + record: dict[str, Any] = {} + try: + for col, value in zip(header, raw, strict=True): + destination = key_map.get(col) + if destination is None: + continue + record[destination] = parse_value(value, COLUMN_TYPES[col]) + except (ValueError, ArithmeticError) as e: + corrupt_skipped += 1 + self.logger.warning( + msg=( + f"Skipping CSV line {line_no}: could not parse column " + f"{col!r} value {value!r} ({e})" + ) + ) + continue + parsed.append(record) + except PermissionError as e: + raise CsvReadError(f"Permission denied reading: {self.file_path}") from e + except UnicodeDecodeError as e: + raise CsvReadError("Encoding error reading CSV.") from e + except OSError as e: + raise CsvReadError(f"Could not read CSV: {self.file_path}") from e + + if length_skipped: + self.logger.warning( + msg=f"Skipped {length_skipped} malformed CSV row(s) (field-count mismatch)." + ) + if corrupt_skipped: + self.logger.warning( + msg=f"Skipped {corrupt_skipped} CSV row(s) with unparseable values." + ) + + # A majority-corrupt file is not a recording with a few bad cells; it is + # the wrong/garbled file. Surface that distinctly instead of returning a + # misleading fragment. (The empty-result check below still covers the + # all-corrupt / no-data cases.) + if corrupt_skipped and corrupt_skipped > _MAX_CORRUPT_ROW_FRACTION * data_row_count: + raise CsvReadError( + f"CSV is mostly unreadable: {corrupt_skipped} of {data_row_count} " + "data rows failed to parse." + ) + + if not parsed: + raise CsvReadError("CSV file is empty or contains no valid data.") + return parsed diff --git a/app/recording_timeline.py b/app/recording_timeline.py new file mode 100644 index 0000000..8d8c0b0 --- /dev/null +++ b/app/recording_timeline.py @@ -0,0 +1,88 @@ +"""Canonical workout timing created once when ergometer packets arrive.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +from typing import Any + + +def _seconds_to_ms(value: Any, field: str) -> int: + """Parse a finite non-negative seconds value at millisecond precision.""" + try: + seconds = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Ergometer packet has invalid {field}") from exc + if not isfinite(seconds) or seconds < 0: + raise ValueError(f"Ergometer packet has invalid {field}") + return round(seconds * 1000) + + +@dataclass(frozen=True) +class RecordTiming: + """Canonical endpoint and represented window for one retained packet.""" + + timestamp_ms: int + session_elapsed_s: float + sample_duration_s: float + gap_before_s: float + + +class RecordingTimeline: + """Turn raw device timing into a monotonic, non-overlapping session clock. + + Advancing device elapsed time remains the primary clock. A stalled or reset + counter is rebased to monotonic receipt time. The raw device window is then + clipped so it never overlaps the preceding represented window; any time not + covered by a sample remains an explicit pause. + """ + + def __init__(self, *, epoch_start_ms: int, monotonic_start_s: float) -> None: + self.epoch_start_ms = int(epoch_start_ms) + self.monotonic_start_s = float(monotonic_start_s) + self._last_device_elapsed_ms: int | None = None + self._last_session_elapsed_ms = 0 + + @property + def session_elapsed_s(self) -> float: + """Latest canonical session endpoint in seconds.""" + return self._last_session_elapsed_ms / 1000.0 + + def advance( + self, + *, + device_elapsed_s: Any, + device_window_s: Any, + receipt_monotonic_s: float, + ) -> RecordTiming: + """Create canonical timing for one raw packet.""" + device_elapsed_ms = _seconds_to_ms(device_elapsed_s, "elapsed_time__s") + requested_duration_ms = _seconds_to_ms(device_window_s, "window_size__s") + receipt_elapsed_ms = max( + round((float(receipt_monotonic_s) - self.monotonic_start_s) * 1000), + 0, + ) + + previous_endpoint_ms = self._last_session_elapsed_ms + if self._last_device_elapsed_ms is None: + endpoint_ms = device_elapsed_ms + elif device_elapsed_ms > self._last_device_elapsed_ms: + endpoint_ms = previous_endpoint_ms + ( + device_elapsed_ms - self._last_device_elapsed_ms + ) + else: + endpoint_ms = max(receipt_elapsed_ms, previous_endpoint_ms) + + available_ms = max(endpoint_ms - previous_endpoint_ms, 0) + represented_ms = min(requested_duration_ms, available_ms) + window_start_ms = endpoint_ms - represented_ms + gap_before_ms = max(window_start_ms - previous_endpoint_ms, 0) + + self._last_device_elapsed_ms = device_elapsed_ms + self._last_session_elapsed_ms = endpoint_ms + return RecordTiming( + timestamp_ms=self.epoch_start_ms + endpoint_ms, + session_elapsed_s=endpoint_ms / 1000.0, + sample_duration_s=represented_ms / 1000.0, + gap_before_s=gap_before_ms / 1000.0, + ) diff --git a/app/recovery.py b/app/recovery.py new file mode 100644 index 0000000..ae5ef73 --- /dev/null +++ b/app/recovery.py @@ -0,0 +1,125 @@ +"""Crash / interruption recovery for workout sessions. + +A workout streams to its CSV one record at a time, so the data of an +interrupted session (app crash, power loss, forced quit) survives on disk -- it +just never got converted to a FIT or uploaded. This module tracks an +"active workout" marker so the GUI can offer to recover such a session on the +next launch. + +Lifecycle: + * The workout session calls :func:`mark_active` when recording starts. + * The GUI calls :func:`clear_active` once the workout has been safely exported + (or the user dismisses its summary), and on the no-data path. + * On startup the GUI calls :func:`pending_recovery`; a non-None result means a + previous session was interrupted and still has data worth recovering. + +The marker is intentionally tiny and self-healing: a stale marker pointing at a +missing or empty CSV is cleared automatically. +""" + +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Any + +from .atomic_json import write_atomic_json +from .workout_paths import WorkoutPaths + +# Marker lives alongside the YAML config in the user's KayakFit folder. +_MARKER_PATH = Path.home() / "KayakFit" / ".active_workout.json" + + +def _marker_path() -> Path: + return _MARKER_PATH + + +def mark_active(workout_dir: str | Path, started_at: datetime | None = None) -> None: + """Record that a workout directory is in progress. + + Failure is propagated because a recording without durable recovery state + must not be reported as safely started. + """ + path = _marker_path() + payload = { + "version": 2, + "state": "recording", + "workout_dir": str(workout_dir), + "started_at": (started_at or datetime.now()).isoformat(timespec="seconds"), + } + write_atomic_json(path, payload) + + +def mark_partial(workout_dir: str | Path, message: str) -> None: + """Durably record a retained workout that needs user recovery.""" + write_atomic_json( + _marker_path(), + { + "version": 2, + "state": "partial", + "workout_dir": str(workout_dir), + "message": message, + "updated_at": datetime.now().isoformat(timespec="seconds"), + }, + ) + + +def clear_active() -> None: + """Remove the active-workout marker if present.""" + path = _marker_path() + path.unlink(missing_ok=True) + if os.name != "nt" and path.parent.exists(): + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def _read_marker() -> dict[str, Any] | None: + path = _marker_path() + if not path.exists(): + return None + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if ( + isinstance(data, dict) + and data.get("version") == 2 + and data.get("state") in ("recording", "partial") + and data.get("workout_dir") + ): + return data + except (OSError, ValueError): + pass + return None + + +def _csv_has_data(csv_path: Path) -> bool: + """True when the CSV has at least one record beyond the header row.""" + try: + with open(csv_path, encoding="utf-8") as f: + f.readline() # header + return bool(f.readline().strip()) + except OSError: + return False + + +def pending_recovery() -> dict[str, Any] | None: + """Return marker info for an interrupted, recoverable workout, else None. + + A marker whose CSV is missing or empty is treated as nothing to recover and + is cleared so the prompt does not reappear. + """ + data = _read_marker() + if not data: + return None + + workout_dir = Path(str(data.get("workout_dir", ""))) + csv_path = WorkoutPaths(workout_dir).csv + if not csv_path.exists() or not _csv_has_data(csv_path): + clear_active() + return None + # The GUI/export worker acts on the canonical CSV; expose it after the + # directory-level marker has been validated. + return {**data, "csv_path": str(csv_path)} diff --git a/app/secret_store.py b/app/secret_store.py new file mode 100644 index 0000000..3b627c4 --- /dev/null +++ b/app/secret_store.py @@ -0,0 +1,150 @@ +"""Complete Strava authorization in the operating system's credential vault. + +Stores one all-or-nothing authorization bundle in the platform's vault: + + - macOS: Keychain + - Windows: Credential Locker + - Linux: Secret Service (if available) + +Backed by the required ``keyring`` package. If no usable backend is present, +``available()`` returns False and the caller keeps the secrets in the config +file instead. + +All credentials are kept in a **single** vault entry (a small JSON blob) rather +than one entry per key. On macOS an unsigned app is prompted for the login +password once per keychain item accessed, so a single entry means a single +prompt at startup. The result is cached for the lifetime of the process, so +repeated config loads never re-prompt. +""" + +import contextlib +import json +from collections.abc import Mapping +from typing import Any + +import keyring +from keyring.backends.fail import Keyring as _FailKeyring + +SERVICE = "KayakFit" + +# Single vault entry holding the complete authorization as JSON (one prompt). +_BLOB_KEY = "strava_secrets" + +# A refreshable connection is one atomic record. Keeping client ID or expiry +# only in config.yml would create a misleading partial connection if that file +# is deleted while Keychain survives. +CREDENTIAL_KEYS = ( + "strava_client_id", + "strava_client_secret", + "strava_access_token", + "strava_refresh_token", + "strava_expires_at", +) + +# Values omitted from plaintext config.yml when the vault is available. +SENSITIVE_KEYS = ( + "strava_client_secret", + "strava_access_token", + "strava_refresh_token", +) + +# Process-lifetime cache so we hit the vault (and prompt) at most once per run. +# None means "not loaded yet". +_cache: dict[str, Any] | None = None + + +def has_complete_credentials(values: Mapping[str, Any]) -> bool: + """Return whether values contain one complete, refreshable authorization.""" + text_keys = CREDENTIAL_KEYS[:-1] + if any( + not isinstance(values.get(key), str) or not values[key].strip() + for key in text_keys + ): + return False + expires_at = values.get("strava_expires_at") + return isinstance(expires_at, int) and not isinstance(expires_at, bool) and expires_at > 0 + + +def _normalize_credentials(values: Mapping[str, Any]) -> dict[str, Any]: + """Return a built-in JSON-safe credential bundle, or an empty dictionary.""" + if not has_complete_credentials(values): + return {} + return { + "strava_client_id": str(values["strava_client_id"]).strip(), + "strava_client_secret": str(values["strava_client_secret"]).strip(), + "strava_access_token": str(values["strava_access_token"]).strip(), + "strava_refresh_token": str(values["strava_refresh_token"]).strip(), + "strava_expires_at": int(values["strava_expires_at"]), + } + + +def available() -> bool: + """Return True if a usable OS credential backend is present.""" + try: + backend = keyring.get_keyring() + if isinstance(backend, _FailKeyring): + return False + return backend is not None + except Exception: + return False + + +def store_credentials(values: Mapping[str, Any]) -> bool: + """Write the complete Strava authorization to one OS-vault entry. + + Args: + values: Configuration mapping. Incomplete authorization deletes the + entry rather than persisting a partial connection. + + Returns: + True if the vault was used, False if it is unavailable (caller should + then fall back to file storage). + """ + global _cache + if not available(): + return False + credentials = _normalize_credentials(values) + try: + if credentials: + keyring.set_password(SERVICE, _BLOB_KEY, json.dumps(credentials)) + else: + if keyring.get_password(SERVICE, _BLOB_KEY) is not None: + keyring.delete_password(SERVICE, _BLOB_KEY) + _cache = dict(credentials) + return True + except Exception: + return False + + +def load_credentials() -> dict[str, Any]: + """Read one complete authorization from the vault. + + Incomplete or malformed entries are rejected and deleted. + """ + global _cache + if _cache is not None: + return dict(_cache) + if not available(): + _cache = {} + return {} + + try: + raw = keyring.get_password(SERVICE, _BLOB_KEY) + except Exception: + raw = None + + credentials: dict[str, Any] = {} + if raw: + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + credentials = _normalize_credentials(parsed) + except ValueError: + credentials = {} + + if raw and not credentials: + with contextlib.suppress(Exception): + keyring.delete_password(SERVICE, _BLOB_KEY) + + _cache = dict(credentials) + return dict(credentials) diff --git a/app/segmentation.py b/app/segmentation.py new file mode 100644 index 0000000..94501d1 --- /dev/null +++ b/app/segmentation.py @@ -0,0 +1,279 @@ +"""Shared activity-run detection. + +A workout is split into *segments* (continuous moving periods) separated by +*stationary* periods. These exact runs own FIT timer events. +``app.finalized_workout`` groups them across brief gaps for free-workout laps +or into performed steps for a planned workout. + + * ``LiveSegmenter`` is the online form used by the live workout session to + drive the dashboard's "Paused" indicator and lap counter in real time. + * ``resolve_segments`` resolves the authoritative activity-record runs. + +Planned-step grouping is intentionally handled later by +``app.finalized_workout``. A plan defines reporting groups; it never changes +which ergometer samples are active. + +The ergometer's activity flag is the sole authority for timer time. Speed and +distance remain metrics; they never infer whether the athlete is paddling. +""" + +import json +from collections.abc import Callable, Mapping, Sequence +from math import isfinite +from pathlib import Path +from typing import Any + +from .coerce import to_float +from .workout_paths import WorkoutPaths + +# A free workout only presents a pause/lap boundary after this much continuous +# inactivity. Exact activity runs remain unchanged for timer time and metrics. +# Using elapsed time instead of packet count keeps the behavior stable when an +# ergometer row represents more than the nominal one-second polling interval. +MEANINGFUL_PAUSE_SECONDS = 5.0 + + +def detect_segments( + *, + active_col: Sequence[Any] | None, + record_windows_ms: Mapping[int, tuple[int, int]], + logger: Any | None = None, +) -> list[dict[str, Any]]: + """Detect active runs from explicit non-overlapping record windows.""" + + def _warning(msg: str) -> None: + if logger is not None: + logger.warning(msg=msg) + + height = len(record_windows_ms) + if active_col is None or len(active_col) != height: + raise ValueError("Workout is not v1: complete activity timeline is required") + exact_segments: list[dict[str, Any]] = [] + current_indices: list[int] = [] + previous_endpoint_ms: int | None = None + + def _close_run() -> None: + if not current_indices: + return + first, last = current_indices[0], current_indices[-1] + exact_segments.append( + { + "start_idx": first, + "end_idx": last, + "record_indices": tuple(current_indices), + "start_time_ms": record_windows_ms[first][0], + "end_time_ms": record_windows_ms[last][1], + } + ) + current_indices.clear() + + for i, active_value in enumerate(active_col): + if isinstance(active_value, bool): + raise ValueError(f"Workout is not v1: invalid activity value at row {i}") + try: + numeric_active = float(active_value) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Workout is not v1: invalid activity value at row {i}" + ) from exc + if not isfinite(numeric_active) or numeric_active not in (0.0, 1.0): + raise ValueError(f"Workout is not v1: invalid activity value at row {i}") + + try: + window_start_ms, window_end_ms = record_windows_ms[i] + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"Workout is not v1: invalid sample window at row {i}") from exc + represented_ms = window_end_ms - window_start_ms + if represented_ms < 0: + raise ValueError(f"Workout is not v1: invalid sample window at row {i}") + if represented_ms == 0: + previous_endpoint_ms = window_end_ms + continue + if previous_endpoint_ms is not None and window_start_ms > previous_endpoint_ms: + _close_run() + if numeric_active == 0.0: + _close_run() + else: + current_indices.append(i) + previous_endpoint_ms = window_end_ms + _close_run() + if not exact_segments: + _warning("No moving segments detected") + return exact_segments + + +def load_program_steps(csv_path: str | Path) -> list[dict[str, Any]] | None: + """Load the program step timeline saved alongside a workout CSV, if any. + + The workout session writes ``steps.json`` in the workout directory when a + structured program was run. Every entry uses canonical session-elapsed + seconds; epoch and raw-device timing are deliberately unsupported. + """ + try: + sidecar = WorkoutPaths.from_csv(csv_path).steps + if not sidecar.exists(): + return None + with open(sidecar, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict) or set(data) != {"version", "complete", "steps"}: + raise ValueError("invalid step-sidecar schema") + if data.get("version") != 1: + raise ValueError("unsupported step-sidecar version") + if not isinstance(data.get("complete"), bool): + raise ValueError("planned-workout completion state is required") + steps = data.get("steps") + if not isinstance(steps, list) or not steps: + raise ValueError("planned workout requires at least one performed step") + previous_end = 0.0 + for ordinal, step in enumerate(steps): + bounds = program_step_bounds(step) + if bounds is None or step.get("index") != ordinal: + raise ValueError("planned workout contains an invalid performed step") + start, end = bounds + if start != previous_end: + raise ValueError("planned workout steps must be consecutive") + previous_end = end + return steps + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise ValueError( + f"Invalid planned-workout timeline {sidecar.name}: {exc}" + ) from exc + + +def program_step_bounds(step: Any) -> tuple[float, float] | None: + """Return strict canonical session-elapsed bounds for one performed step.""" + required = { + "index", "type", "label", "start_elapsed_s", "end_elapsed_s" + } + if not isinstance(step, dict) or set(step) != required: + return None + elapsed_start = step.get("start_elapsed_s") + elapsed_end = step.get("end_elapsed_s") + if ( + isinstance(step.get("index"), bool) + or not isinstance(step.get("index"), int) + or step["index"] < 0 + or not isinstance(step.get("type"), str) + or not step["type"] + or not isinstance(step.get("label"), str) + or not step["label"] + or isinstance(elapsed_start, bool) + or isinstance(elapsed_end, bool) + or not isinstance(elapsed_start, (int, float)) + or not isinstance(elapsed_end, (int, float)) + ): + return None + start_value, end_value = float(elapsed_start), float(elapsed_end) + if ( + not isfinite(start_value) + or not isfinite(end_value) + or start_value < 0 + or end_value < start_value + ): + return None + return start_value, end_value + + +def resolve_segments( + *, + record_windows_ms: Mapping[int, tuple[int, int]], + active_col: Sequence[Any], + logger: Any | None = None, +) -> list[dict[str, Any]]: + """Resolve exact active-record runs independently of lap grouping.""" + return detect_segments( + active_col=active_col, + record_windows_ms=record_windows_ms, + logger=logger, + ) + + +class LiveSegmenter: + """Online free-workout pause/lap presenter fed one record at a time. + + Exact activity transitions still own finalized timer time. Presentation is + debounced so brief inactive flags between low-cadence strokes do not flicker + the dashboard or create one-second free-workout laps. + """ + + def __init__( + self, on_event: Callable[[dict[str, Any]], None] | None = None + ) -> None: + """Initialize the detector. + + Args: + on_event: Optional callback invoked on every state transition with + the same dict returned by :meth:`update`. + """ + self.on_event = on_event + self.reset() + + def reset(self) -> None: + """Clear all state for a fresh workout.""" + self.paused = False + self.lap = 0 + self._moving_started = False + self._stationary_s = 0.0 + + def _advance_lap(self) -> None: + """Count the active segment just closed.""" + self.lap += 1 + + def update( + self, + *, + active: Any, + sample_duration_s: Any = 1, + gap_before_s: Any = 0, + ) -> dict[str, Any]: + """Feed one record and return the current state. + + ``active`` is the required ergometer "actively paddling" flag. Returns + a dict ``{"paused", "lap", "transition"}`` + where ``transition`` is one of ``None``, ``"start"``, ``"pause"`` or + ``"resume"``. + """ + current_active = to_float(active) + if isinstance(active, bool) or current_active not in (0.0, 1.0): + raise ValueError("Live v1 workout requires an activity signal") + transition: str | None = None + + duration = to_float(sample_duration_s) + gap = to_float(gap_before_s) + if duration is None or duration < 0 or gap is None or gap < 0: + raise ValueError("Live v1 workout requires valid canonical timing") + + stationary = current_active <= 0 + if stationary: + if self._moving_started and not self.paused: + self._stationary_s += gap + duration + if ( + self._stationary_s >= MEANINGFUL_PAUSE_SECONDS + and not self.paused + and self._moving_started + ): + self.paused = True + transition = "pause" + else: + if self.paused: + self.paused = False + self._advance_lap() + transition = "resume" + elif ( + self._moving_started + and self._stationary_s + gap >= MEANINGFUL_PAUSE_SECONDS + ): + # No packet arrived while a long uncovered gap was in progress, + # so the first observable transition is the resumed state. + self._advance_lap() + transition = "resume" + elif not self._moving_started: + self._moving_started = True + self.lap = 1 + transition = "start" + self._stationary_s = 0.0 + + result = {"paused": self.paused, "lap": self.lap, "transition": transition} + if transition is not None and self.on_event is not None: + self.on_event(result) + return result diff --git a/app/sound.py b/app/sound.py new file mode 100644 index 0000000..5d5ba15 --- /dev/null +++ b/app/sound.py @@ -0,0 +1,146 @@ +"""Simple cross-platform audible cues for structured programs. + +Cues signal an upcoming or just-happened program change (warm-up → work, work → +rest, a step about to end, program complete) so the athlete knows a change is +expected without watching the screen. Auto-pause/resume is deliberately silent: +the athlete already knows when they stop and start paddling. + +The tones are synthesized at runtime from one shared table, so every platform +sounds identical and no audio files are bundled. Meaning is carried by pitch: +rising = go/start, falling = ease off/stop. Playback is best-effort — any +failure is swallowed so audio can never disrupt a workout. + +Cue names: "warmup", "work", "rest", "cooldown", "phase", "complete", "alert", +"countdown". +""" + +import contextlib +import hashlib +import math +import os +import queue +import struct +import subprocess +import sys +import tempfile +import threading +import wave + +# One tone table drives every platform. Each cue is a sequence of +# (frequency_Hz, duration_ms) notes; pitch direction carries the meaning. +_CUE_TONES: dict[str, list[tuple[int, int]]] = { + "warmup": [(523, 140), (659, 140), (784, 240)], # C5-E5-G5 gentle climb = "ease in" + "work": [(1047, 130), (1319, 190)], # C6->E6 sharp rise = "go" + "rest": [(784, 150), (523, 240)], # G5->C5 falling = "ease off" + "cooldown": [(784, 180), (659, 180), (523, 300)], # G5-E5-C5 gentle fall = "wind down" + "phase": [(880, 140), (1047, 190)], # A5->C6, generic "next segment" + "complete": [(1047, 130), (1319, 130), (1568, 320)], # C-E-G rising = "done" + "alert": [(932, 120), (740, 200)], # Bb5->F#5 falling = "check effort" + "countdown": [(1319, 240)], # E6, bright single "about to change" +} + +_SAMPLE_RATE = 44100 +_CUE_DIR = os.path.join(tempfile.gettempdir(), "kayakfit_cues") + + +def _cue_wav(name: str) -> str: + """Return a cached WAV path for the cue, rendering it once on first use.""" + notes = _CUE_TONES.get(name, _CUE_TONES["phase"]) + key = hashlib.md5(repr(notes).encode()).hexdigest()[:8] + path = os.path.join(_CUE_DIR, f"{name}_{key}.wav") + if not os.path.exists(path): + _render_wav(path, notes) + return path + + +def _render_wav(path: str, notes: list[tuple[int, int]]) -> None: + """Synthesize a short sine-tone sequence (with fades) to a mono WAV file.""" + frames = bytearray() + fade = int(_SAMPLE_RATE * 0.006) # 6 ms fade in/out, avoids clicks + gap = b"\x00\x00" * int(_SAMPLE_RATE * 0.03) # 30 ms silence between notes + for freq, ms in notes: + n = int(_SAMPLE_RATE * ms / 1000) + for i in range(n): + env = min(1.0, i / fade, (n - i) / fade) if fade else 1.0 + val = int(0.6 * env * 32767 * math.sin(2 * math.pi * freq * i / _SAMPLE_RATE)) + frames += struct.pack(" None: + self.enabled = enabled + self._queue: queue.Queue[str] = queue.Queue(maxsize=self._MAX_PENDING) + threading.Thread(target=self._run, daemon=True).start() + + def set_enabled(self, enabled: bool) -> None: + """Enable or disable audible cue playback.""" + self.enabled = enabled + + def cue(self, name: str) -> None: + """Queue the named cue (non-blocking, best-effort).""" + if not self.enabled: + return + # Already backed up; drop this cue rather than play it late. + with contextlib.suppress(queue.Full): + self._queue.put_nowait(name) + + def _run(self) -> None: + while True: + self._play(self._queue.get()) + + def _play(self, name: str) -> None: + with contextlib.suppress(Exception): + path = _cue_wav(name) + if sys.platform == "darwin": + subprocess.run( + ["afplay", path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + ) + elif sys.platform.startswith("win"): + import winsound + + winsound.PlaySound(path, winsound.SND_FILENAME) + else: + # Linux / other: best-effort. Try a desktop WAV player, else + # fall back to the terminal bell. + if not self._linux_play(path): + sys.stdout.write("\a") + sys.stdout.flush() + + @staticmethod + def _linux_play(path: str) -> bool: + """Try common Linux WAV players; return True if one was invoked.""" + for player in ("paplay", "aplay", "play"): + try: + subprocess.run( + [player, path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + ) + return True + except FileNotFoundError: + continue + except Exception: + return False + return False diff --git a/app/speed_series.py b/app/speed_series.py new file mode 100644 index 0000000..adce3f9 --- /dev/null +++ b/app/speed_series.py @@ -0,0 +1,88 @@ +"""Recorded workout speed shared by summary, FIT and the live UI. + +Instantaneous ergometer speed is the canonical finalized speed channel. +""" + +from __future__ import annotations + +from math import isfinite +from typing import Any + +from .active_distance import ActiveDistanceAccumulator +from .finalized_workout import FinalizedWorkout +from .table import Table + + +def recorded_speed_mps( + table: Table, finalized: FinalizedWorkout +) -> dict[int, float | None]: + """Return valid instantaneous speed for every active record index.""" + values = table.get_column("speed_instant") + result: dict[int, float | None] = {} + for index in finalized.moving_indices: + try: + speed = float(values[index]) + except (IndexError, TypeError, ValueError): + result[index] = None + continue + result[index] = speed if 0 <= speed <= 65.535 else None + return result + + +class LiveSpeedTracker: + """Live view of the same active-distance accumulator used by finalization.""" + + def __init__(self) -> None: + self._distance = ActiveDistanceAccumulator() + self._speed_weighted_sum = 0.0 + self._speed_weight_s = 0.0 + self.max_speed_mps: float | None = None + + @property + def active_distance_m(self) -> float: + return self._distance.active_distance_m + + @property + def active_time_s(self) -> float: + return self._distance.active_time_s + + @property + def avg_speed_mps(self) -> float | None: + """Device-time-weighted instantaneous speed over active intervals.""" + if self._speed_weight_s > 0: + return self._speed_weighted_sum / self._speed_weight_s + return None + + def update( + self, + *, + distance_m: Any, + duration_s: Any, + active: Any, + reported_speed_mps: Any = None, + ) -> None: + """Fold one live sample into active distance, time, average and maximum.""" + self._distance.update( + distance_m=distance_m, + duration_s=duration_s, + active=active, + ) + try: + active_now = float(active) > 0 + duration = float(duration_s) + speed_mps = float(reported_speed_mps) + except (TypeError, ValueError): + return + if ( + not active_now + or not isfinite(duration) + or duration <= 0 + or not 0 <= speed_mps <= 65.535 + ): + return + self._speed_weighted_sum += speed_mps * duration + self._speed_weight_s += duration + if ( + self.max_speed_mps is None or speed_mps > self.max_speed_mps + ): + self.max_speed_mps = speed_mps diff --git a/app/stats.py b/app/stats.py new file mode 100644 index 0000000..76756f4 --- /dev/null +++ b/app/stats.py @@ -0,0 +1,279 @@ +"""Shared workout-metric aggregation. + +The single, correct definition of how a slice of workout samples becomes the +averaged/peak numbers shown to the athlete and written into the FIT file. +``app.workout_metrics.compute_finalized_metrics`` supplies both consumers, so +they cannot silently disagree. This follows the same +"one source of truth" discipline ``app.segmentation`` provides for lap +boundaries. + +Heart-rate zone rules live here too: :func:`zone_lower_bounds` (the single +definition of the five Z1..Z5 bpm lower bounds, manual or derived from max HR) +and :func:`time_in_hr_zones` (time-in-zone accumulated **only inside moving +segments**). The FIT file only contains records inside moving segments — +pauses sit between STOP_ALL/START timer events — so any platform reading it +(Garmin Connect, Strava) derives zone time from moving time only. The in-app +summary must use the same rule or its zone bar won't add up to the moving time +it displays. Missing-HR moving time remains explicit uncovered time in the UI. + +Two rules matter and were previously implemented inconsistently: + +1. **Heart rate, cadence and power average/peak over strictly-positive + readings only.** A ``0`` is a dropped sensor sample (no signal), not a real + physiological zero; including it drags every average toward zero. (The FIT + exporter used to skip only ``None``, so a run of 0-bpm dropouts deflated its + averages relative to the summary screen.) +2. **Average and maximum speed come from the recorded instantaneous speed + samples.** Average speed is weighted by each active represented interval; + maximum speed is the largest active sample. Cumulative distance remains an + independent recorded measurement and is never used to manufacture a speed + spike. + +This module lives apart from ``app.table`` (whose ``column_mean``/``column_max`` +are deliberately generic, None-skipping aggregates) and ``app.segmentation`` +(which is about laps, not stats): this is domain-specific workout-metric logic. +It returns plain floats/ints; each caller coerces to its own field types +(``Decimal``/``int`` for FIT's typed fields, ``float``/``int`` for the GUI). +""" + +from collections.abc import Sequence +from math import isfinite +from typing import Any + +from .finalized_workout import ActivitySegment +from .power import DEFAULT_PULL_LENGTH_M, estimate_power + +# Fractions of max HR used to derive the five zone lower bounds when explicit +# bpm boundaries are not configured. Index 0 is Z1 (recovery) ... 4 is Z5 (max). +ZONE_FRACTIONS = (0.0, 0.60, 0.70, 0.80, 0.90) + + +def zone_lower_bounds( + max_hr: Any = 185, zones: list[int] | None = None +) -> list[int]: + """Return the five ascending zone lower bounds (bpm), Z1..Z5. + + Uses explicit ``zones`` (a list of five ascending bpm values) when provided + and valid; otherwise derives them from ``max_hr`` via ``ZONE_FRACTIONS``. + """ + if zones: + cleaned: list[int] = [] + try: + cleaned = [int(z) for z in zones] + except (TypeError, ValueError): + cleaned = [] + # Require five strictly ascending values to trust a manual config. + if len(cleaned) == 5 and all( + cleaned[i] < cleaned[i + 1] for i in range(4) + ): + return cleaned + try: + mhr = float(max_hr) + except (TypeError, ValueError): + mhr = 185.0 + return [round(frac * mhr) for frac in ZONE_FRACTIONS] + + +def time_in_hr_zones( + *, + heart_rate: Sequence[Any], + segments: Sequence[ActivitySegment], + bounds: Sequence[int], + sample_weights: Sequence[Any], +) -> list[float]: + """Accumulate time in each HR zone over the moving segments only. + + Each active record contributes its explicit represented sample window to + the zone of its heart-rate reading. + + Args: + heart_rate: Per-record heart-rate readings; None/0/unparseable values + are treated as no signal and credited to no zone. + segments: Finalized active segments shared with FIT timer events. + bounds: Ascending zone lower bounds (bpm), e.g. from + :func:`zone_lower_bounds`. + sample_weights: Represented seconds per record. + + Returns: + Seconds per zone, one entry per bound (Z1..Zn). + """ + zone_seconds = [0.0] * len(bounds) + last = len(heart_rate) - 1 + for seg in segments: + for i in seg.record_indices: + if i < 0 or i > last: + continue + hr_raw = heart_rate[i] + if hr_raw is None: + continue + try: + hr = float(hr_raw) + except (TypeError, ValueError): + continue + if hr <= 0: + continue + try: + dt = float(sample_weights[i]) + except (IndexError, TypeError, ValueError): + continue + if dt <= 0: + continue + zone = 0 + for z, lower in enumerate(bounds): + if hr >= lower: + zone = z + zone_seconds[zone] += dt + return zone_seconds + + +def _positive_floats( + values: Sequence[Any] | None, maximum: float | None = None +) -> list[float]: + """Coerce to float and keep only strictly-positive, non-None readings.""" + out: list[float] = [] + if not values: + return out + for v in values: + if v is None: + continue + try: + f = float(v) + except (TypeError, ValueError): + continue + if f > 0 and (maximum is None or f <= maximum): + out.append(f) + return out + + +def _weighted_positive_mean( + values: Sequence[Any] | None, + weights: Sequence[Any], + maximum: float | None = None, +) -> float | None: + """Mean of positive readings, weighted by their represented seconds.""" + weighted_sum = 0.0 + total_weight = 0.0 + for value, weight in zip(values or (), weights, strict=False): + try: + reading = float(value) + seconds = float(weight) + except (TypeError, ValueError): + continue + if reading > 0 and seconds > 0 and (maximum is None or reading <= maximum): + weighted_sum += reading * seconds + total_weight += seconds + return weighted_sum / total_weight if total_weight > 0 else None + + +def positive_power_values( + power_pull_force: Sequence[Any] | None, + power_cadence: Sequence[Any] | None, + pull_length_m: Any = DEFAULT_PULL_LENGTH_M, +) -> list[float]: + """Per-record power estimates (W), keeping only strictly-positive ones.""" + if power_pull_force is None or power_cadence is None: + return [] + powers: list[float] = [] + for pf, cd in zip(power_pull_force, power_cadence, strict=False): + p = estimate_power(pf, cd, pull_length_m) + if p is not None and 0 < p <= 65535: + powers.append(float(p)) + return powers + + +def aggregate_metrics( + *, + heart_rate: Sequence[Any] | None = None, + cadence: Sequence[Any] | None = None, + power_cadence: Sequence[Any] | None = None, + power_pull_force: Sequence[Any] | None = None, + speed: Sequence[Any] | None = None, + sample_weights: Sequence[Any], + pull_length_m: Any = DEFAULT_PULL_LENGTH_M, +) -> dict[str, float | None]: + """Compute averaged/peak workout metrics with the shared rules. + + Args: + heart_rate: per-record heart-rate readings (any may be missing/None; + 0 readings are treated as no signal). + cadence: per-record instantaneous cadence readings (same missing/zero + handling). + power_cadence: per-record instantaneous cadence used for power. + power_pull_force: per-record instantaneous pull force used for power. + speed: per-record instantaneous speed readings. Average speed is + time-weighted; maximum speed is the largest valid reading. + sample_weights: Represented seconds per sensor sample. HR, cadence, + power, and speed averages are time-weighted. + pull_length_m: Effective handle pull length used for power estimation. + + Returns: + Dict of plain floats (or None) with keys ``avg_heart_rate``, + ``max_heart_rate``, ``avg_cadence``, ``max_cadence``, ``avg_power``, + ``max_power``, ``avg_speed``, ``max_speed``. Averages/peaks are over + strictly-positive readings. Speed zero is retained as a real active + reading so a slow start is represented honestly. Callers coerce types. + """ + result: dict[str, float | None] = { + "avg_heart_rate": None, + "max_heart_rate": None, + "avg_cadence": None, + "max_cadence": None, + "avg_power": None, + "max_power": None, + "avg_speed": None, + "max_speed": None, + } + + hr = _positive_floats(heart_rate, 255) + if hr: + result["avg_heart_rate"] = _weighted_positive_mean( + heart_rate, sample_weights, 255 + ) + result["max_heart_rate"] = max(hr) + + cad = _positive_floats(cadence, 255) + if cad: + result["avg_cadence"] = _weighted_positive_mean(cadence, sample_weights, 255) + result["max_cadence"] = max(cad) + + powers = positive_power_values( + power_pull_force, power_cadence, pull_length_m + ) + if powers: + aligned_power = [ + estimate_power(force, rate, pull_length_m) + for force, rate in zip( + power_pull_force or (), power_cadence or (), strict=False + ) + ] + result["avg_power"] = _weighted_positive_mean( + aligned_power, sample_weights, 65535 + ) + result["max_power"] = max(powers) + + speed_values: list[float] = [] + if speed is not None: + for value in speed: + try: + reading = float(value) + except (TypeError, ValueError): + continue + if 0 <= reading <= 65.535: + speed_values.append(reading) + if speed_values: + weighted_sum = 0.0 + total_weight = 0.0 + for value, weight in zip(speed or (), sample_weights, strict=False): + try: + reading = float(value) + seconds = float(weight) + except (TypeError, ValueError): + continue + if 0 <= reading <= 65.535 and isfinite(seconds) and seconds > 0: + weighted_sum += reading * seconds + total_weight += seconds + if total_weight > 0: + result["avg_speed"] = weighted_sum / total_weight + result["max_speed"] = max(speed_values) + + return result diff --git a/app/strava_api.py b/app/strava_api.py new file mode 100644 index 0000000..10b9a17 --- /dev/null +++ b/app/strava_api.py @@ -0,0 +1,255 @@ +"""Minimal Strava REST client built directly on ``requests``. + +Implements the handful of endpoints KayakFit uses: the OAuth authorize URL, +token exchange and refresh, activity upload, and upload/activity read-backs. + +This module is the single HTTP seam: it owns the endpoint URLs, turns any +non-success into a clear :class:`StravaApiError` (HTTP status, network error, +rate limiting, or malformed JSON), and is the only place tests need to stub +``requests``. The higher-level flow (browser callback, token-refresh margin, +upload polling) stays in ``strava_auth`` / ``strava_uploader``. +""" + +from collections.abc import Sequence +from typing import Any, cast +from urllib.parse import urlencode + +# Explicit re-export (redundant alias) so mypy --strict's no_implicit_reexport +# treats `strava_api.requests` as public: the test suite monkeypatches this +# attribute to swap in a fake requests module. +import requests as requests + +# ---- endpoints ------------------------------------------------------------- +AUTHORIZE_URL = "https://www.strava.com/oauth/authorize" +TOKEN_URL = "https://www.strava.com/oauth/token" +UPLOADS_URL = "https://www.strava.com/api/v3/uploads" +ACTIVITIES_URL = "https://www.strava.com/api/v3/activities" + +# Default per-request timeout (seconds). Uploads/polls are short REST calls. +DEFAULT_TIMEOUT = 30 + + +class StravaApiError(Exception): + """Any non-successful Strava interaction. + + Covers an HTTP error, network failure, rate limiting, or a + malformed/unexpected response body. + """ + + +class ActivityUploadError(StravaApiError): + """Strava accepted the upload but reported an error while processing it. + + The upload object's ``error`` field is set. + """ + + +class UploadTimeoutError(StravaApiError): + """Client-side timeout while Strava was still processing the upload. + + Raised when the caller's poll window elapsed before Strava finished. + """ + + +def _message_from_response(resp: requests.Response) -> str: + """Build the clearest human-readable message a Strava error response offers. + + Strava error bodies look like ``{"message": "Bad Request", "errors": + [{"resource": "AuthorizationCode", "field": "code", "code": "invalid"}]}``. + Fall back to the raw body (truncated) or the bare status code. + """ + try: + payload = resp.json() + except ValueError: + text = (resp.text or "").strip() + return text[:200] if text else f"HTTP {resp.status_code}" + + if isinstance(payload, dict): + parts: list[str] = [] + message = payload.get("message") + if message: + parts.append(str(message)) + errors = payload.get("errors") + if isinstance(errors, list) and errors: + detail = "; ".join( + " ".join( + str(v) + for v in ( + e.get("resource"), + e.get("field"), + e.get("code"), + ) + if v + ) + for e in errors + if isinstance(e, dict) + ).strip() + if detail: + parts.append(f"({detail})") + if parts: + return " ".join(parts) + return f"HTTP {resp.status_code}" + + +def _request( + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + data: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, + timeout: float = DEFAULT_TIMEOUT, +) -> dict[str, Any]: + """Perform one Strava request and return the parsed JSON body. + + Raises: + StravaApiError: on a network error, a rate-limit (429), any non-2xx + status, or a body that is not valid JSON. + """ + try: + resp = requests.request( + method, + url, + headers=headers, + data=data, + files=files, + timeout=timeout, + ) + except requests.exceptions.RequestException as e: + raise StravaApiError(f"Network error contacting Strava: {e}") from e + + if resp.status_code == 429: + raise StravaApiError( + "Strava rate limit exceeded (HTTP 429). Please try again later." + ) + if not resp.ok: + raise StravaApiError( + f"Strava API error (HTTP {resp.status_code}): " + f"{_message_from_response(resp)}" + ) + + try: + # resp.json() is typed Any; the Strava endpoints we call return JSON + # objects, so narrow to the declared dict return. + return cast(dict[str, Any], resp.json()) + except ValueError as e: + raise StravaApiError(f"Strava returned a malformed response: {e}") from e + + +def _extract_tokens(payload: Any) -> dict[str, Any]: + """Pull the token triple out of a token-endpoint response.""" + try: + return { + "access_token": payload["access_token"], + "refresh_token": payload["refresh_token"], + "expires_at": int(payload["expires_at"]), + } + except (KeyError, TypeError, ValueError) as e: + raise StravaApiError( + f"Strava token response missing expected fields: {e}" + ) from e + + +def build_authorize_url( + client_id: Any, + redirect_uri: str, + scope: Sequence[str], + state: str, + approval_prompt: str = "auto", +) -> str: + """Build Strava's OAuth authorization URL. + + Mirrors stravalib's ``Client.authorization_url``: scope is comma-joined and + ``approval_prompt`` defaults to ``"auto"``. ``client_id`` is coerced to int + so a non-numeric value fails fast (the caller reports it as an invalid id). + """ + params = { + "client_id": int(client_id), + "redirect_uri": redirect_uri, + "response_type": "code", + "approval_prompt": approval_prompt, + "scope": ",".join(scope), + "state": state, + } + return f"{AUTHORIZE_URL}?{urlencode(params)}" + + +def exchange_code_for_token( + client_id: Any, client_secret: str, code: str +) -> dict[str, Any]: + """Exchange an authorization code for tokens (grant_type=authorization_code).""" + payload = _request( + "POST", + TOKEN_URL, + data={ + "client_id": int(client_id), + "client_secret": client_secret, + "code": code, + "grant_type": "authorization_code", + }, + ) + return _extract_tokens(payload) + + +def refresh_access_token( + client_id: Any, client_secret: str, refresh_token: str +) -> dict[str, Any]: + """Refresh an access token (grant_type=refresh_token).""" + payload = _request( + "POST", + TOKEN_URL, + data={ + "client_id": int(client_id), + "client_secret": client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + ) + return _extract_tokens(payload) + + +def upload_activity( + access_token: str, + file_obj: Any, + data_type: str, + name: str | None = None, + description: str | None = None, + external_id: str | None = None, +) -> dict[str, Any]: + """POST an activity file to Strava's upload endpoint. + + Returns the upload object (``{"id": ..., "status": ..., "error": ..., + "activity_id": ...}``); ``activity_id`` is null until processing finishes. + """ + data: dict[str, Any] = {"data_type": data_type} + if name is not None: + data["name"] = name + if description is not None: + data["description"] = description + if external_id is not None: + data["external_id"] = external_id + return _request( + "POST", + UPLOADS_URL, + headers={"Authorization": f"Bearer {access_token}"}, + data=data, + files={"file": file_obj}, + ) + + +def get_upload(access_token: str, upload_id: Any) -> dict[str, Any]: + """Read back an upload's processing status.""" + return _request( + "GET", + f"{UPLOADS_URL}/{upload_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + +def get_activity(access_token: str, activity_id: Any) -> dict[str, Any]: + """Read back a created activity (used to confirm the final activity id).""" + return _request( + "GET", + f"{ACTIVITIES_URL}/{activity_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) diff --git a/app/strava_auth.py b/app/strava_auth.py new file mode 100644 index 0000000..0c5a2a9 --- /dev/null +++ b/app/strava_auth.py @@ -0,0 +1,238 @@ +"""Strava OAuth2 authorization-code flow. + +Builds the authorization URL and exchanges the returned code for tokens using +direct Strava REST calls (via ``app.strava_api``). + +The redirect target is the loopback IP ``http://127.0.0.1:{port}`` (per RFC 8252 +for native apps). Using the IP literal rather than ``localhost`` avoids Safari's +"HTTPS-first" upgrade, which would otherwise rewrite the callback to https and +break the local HTTP listener. + +A short-lived local HTTP server captures the redirect automatically. If that +doesn't arrive (e.g. a browser that still blocks the loopback request), the +caller can supply ``manual_code_provider`` to prompt the user to paste the +redirected URL (or bare code) instead. This module stays GUI-free; the prompt is +injected by the caller. + +Register ``127.0.0.1`` as the Authorization Callback Domain in your Strava API +application settings. +""" + +import re +import secrets +import time +import webbrowser +from collections.abc import Callable +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + +from . import strava_api +from .logger import Logger + +# Seconds to wait for the automatic callback before offering manual entry. +AUTO_CAPTURE_TIMEOUT = 25 +# Seconds to wait when there is no manual fallback (e.g. command-line use). +AUTO_ONLY_TIMEOUT = 120 +# activity:write to upload; activity:read because the upload flow reads the +# created activity back after processing (GET /activities/{id}). +SCOPES = ["activity:write", "activity:read"] + +ManualCodeProvider = Callable[[str], str | None] + + +class _CallbackHandler(BaseHTTPRequestHandler): + """Captures the OAuth ``code`` (or ``error``) from the redirect. + + The ``state`` parameter is required and must match the value generated for + this flow (RFC 8252 §8.9): it stops another local process or a malicious + web page from injecting its own authorization code into the loopback + listener, which would silently connect the app to an attacker's account. + """ + + auth_code: str | None = None + auth_error: str | None = None + expected_state: str | None = None + + def do_GET(self) -> None: + params = parse_qs(urlparse(self.path).query) + state = params.get("state", [None])[0] + state_ok = ( + _CallbackHandler.expected_state is not None + and state is not None + and secrets.compare_digest(state, _CallbackHandler.expected_state) + ) + # Both successful and denied OAuth responses are terminal. Requiring + # state for either prevents an unrelated local request from aborting + # the in-progress authorization flow. + if ("code" in params or "error" in params) and not state_ok: + self.send_response(400) + self.end_headers() + return + if "code" in params: + _CallbackHandler.auth_code = params["code"][0] + body = ( + b"

KayakFit is connected to Strava.

" + b"

You can close this tab.

" + ) + elif "error" in params: + _CallbackHandler.auth_error = params["error"][0] + body = ( + b"

Authorization failed.

" + b"

You can close this tab.

" + ) + else: + self.send_response(404) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args: Any) -> None: # silence server logging + pass + + +class StravaAuth: + """Runs the Strava OAuth authorization-code flow.""" + + def __init__(self, client_id: str, client_secret: str, log_level: str = "INFO") -> None: + self.client_id = client_id + self.client_secret = client_secret + self.logger = Logger.get_logger(name=__name__) + + @staticmethod + def _start_server( + start_port: int = 8000, max_attempts: int = 10 + ) -> HTTPServer | None: + """Bind the loopback callback server on the first free port. + + Binding directly (rather than probing for a free port first) avoids the + race where another process grabs the port between probe and bind. + """ + for port in range(start_port, start_port + max_attempts): + try: + server = HTTPServer(("127.0.0.1", port), _CallbackHandler) + server.socket.settimeout(2.0) + return server + except OSError: + continue + return None + + @staticmethod + def extract_code(text: str | None, expected_state: str | None = None) -> str | None: + """Extract the OAuth code from a pasted full redirect URL or a bare code. + + If the pasted text carries a ``state`` parameter and ``expected_state`` + is given, a mismatch is rejected (anti-CSRF, RFC 8252 §8.9). A bare + pasted code has no state to check; it is accepted as user-mediated. + """ + if not text: + return None + text = text.strip() + if expected_state is not None and "state=" in text: + query = urlparse(text).query or text + states = parse_qs(query).get("state") + pasted_state = states[0] if states else None + if pasted_state is None or not secrets.compare_digest(pasted_state, expected_state): + return None + if "code=" in text: + query = urlparse(text).query or text + codes = parse_qs(query).get("code") + if codes: + return codes[0] + match = re.search(r"code=([^&\s]+)", text) + return match.group(1) if match else None + # Assume the user pasted the bare code (Strava codes have no spaces). + return text if text and " " not in text else None + + def _capture_code(self, server: HTTPServer, auth_url: str, timeout: int) -> str | None: + """Open the browser and wait for the loopback callback to deliver a code.""" + try: + self.logger.info(msg="Opening Strava authorization page in your browser...") + webbrowser.open(auth_url) + + deadline = time.time() + timeout + while ( + _CallbackHandler.auth_code is None + and _CallbackHandler.auth_error is None + and time.time() < deadline + ): + try: + server.handle_request() + except TimeoutError: + continue + except OSError as e: + self.logger.error(msg=f"Local callback server error: {e}") + finally: + server.server_close() + + if _CallbackHandler.auth_error: + self.logger.error(msg=f"Authorization denied: {_CallbackHandler.auth_error}") + return _CallbackHandler.auth_code + + def start_auth_flow( + self, manual_code_provider: ManualCodeProvider | None = None + ) -> dict[str, Any] | None: + """Run the browser authorization flow and exchange the code for tokens. + + Args: + manual_code_provider: Optional callback invoked with the auth URL if + the automatic callback doesn't arrive; it should return the + pasted redirect URL or bare code (or None if cancelled). + + Returns: + Dict with ``access_token``, ``refresh_token`` and ``expires_at`` on + success, or None on failure/timeout. + """ + server = self._start_server() + if server is None: + self.logger.error(msg="Could not find a free local port for the callback.") + return None + + # Fresh anti-CSRF state per flow; the callback rejects mismatches. + state = secrets.token_urlsafe(32) + _CallbackHandler.auth_code = None + _CallbackHandler.auth_error = None + _CallbackHandler.expected_state = state + + redirect_uri = f"http://127.0.0.1:{server.server_address[1]}" + try: + auth_url = strava_api.build_authorize_url( + client_id=self.client_id, + redirect_uri=redirect_uri, + scope=SCOPES, + state=state, + ) + except (ValueError, TypeError) as e: + self.logger.error(msg=f"Invalid Strava client id: {e}") + server.server_close() + return None + + timeout = AUTO_CAPTURE_TIMEOUT if manual_code_provider else AUTO_ONLY_TIMEOUT + code = self._capture_code(server, auth_url, timeout) + + if code is None and manual_code_provider is not None: + self.logger.info(msg="Automatic callback not received; requesting manual code entry.") + code = self.extract_code(manual_code_provider(auth_url), expected_state=state) + + if code is None: + self.logger.error(msg="No authorization code received.") + return None + + try: + info = strava_api.exchange_code_for_token( + client_id=self.client_id, + client_secret=self.client_secret, + code=code, + ) + self.logger.info(msg="Successfully obtained Strava tokens.") + return { + "access_token": info["access_token"], + "refresh_token": info["refresh_token"], + "expires_at": int(info["expires_at"]), + } + except Exception as e: + self.logger.error(msg=f"Token exchange failed: {e}") + return None diff --git a/app/strava_uploader.py b/app/strava_uploader.py new file mode 100644 index 0000000..4a00674 --- /dev/null +++ b/app/strava_uploader.py @@ -0,0 +1,248 @@ +"""Strava activity uploading. + +Uploads kayaking activities to Strava using direct REST calls (via +``app.strava_api``): CSV→FIT conversion before upload, the multipart upload, +the asynchronous processing poll (cancellable early through an optional stop +event), and proactive token refresh via the stored ``expires_at``. +""" + +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from . import strava_api +from .logger import Logger +from .workout_paths import FIT_FILENAME, WORKOUT_DIRECTORY_PREFIX + +# Refresh the access token this many seconds before it actually expires. +TOKEN_REFRESH_MARGIN = 120 +# Max seconds to wait for Strava to finish processing an upload. +UPLOAD_WAIT_TIMEOUT = 90 +# How often to poll the upload status while waiting for processing. +UPLOAD_POLL_INTERVAL = 2.0 + + +class StravaUploader: + """Uploads activities to Strava via the ``app.strava_api`` REST layer.""" + + def __init__( + self, + access_token: str, + refresh_token: str, + client_id: str, + client_secret: str, + config: dict[str, Any], + log_callback: Callable[[str], None] | None = None, + expires_at: int = 0, + stop_event: Any | None = None, + ) -> None: + """Initialize Strava uploader. + + Args: + access_token: Strava access token. + refresh_token: Strava refresh token. + client_id: Strava API client ID. + client_secret: Strava API client secret. + config: App configuration. + log_callback: Optional callback function for logging. + expires_at: Unix time (s) when the access token expires (0 if unknown). + stop_event: Optional ``threading.Event``-like object (only ``.is_set()`` + is used). When set, the upload-processing poll stops early instead + of waiting out the full timeout; ``None`` disables the check. + """ + self.access_token = access_token + self.refresh_token = refresh_token + self.client_id = client_id + self.client_secret = client_secret + self.expires_at = int(expires_at or 0) + self.config = config + self.stop_event = stop_event + + Logger.setup( + log_level=self.config.get("log_level", "INFO"), log_callback=log_callback + ) + self.logger = Logger.get_logger(name=__name__) + + def _ensure_fresh_token(self) -> bool: + """Refresh the access token if it is missing or about to expire. + + Returns: + True if a usable access token is available, False otherwise. + """ + token_valid = ( + self.access_token + and self.expires_at + and time.time() < self.expires_at - TOKEN_REFRESH_MARGIN + ) + if token_valid: + return True + + if not self.refresh_token: + return bool(self.access_token) + + try: + self.logger.info(msg="Refreshing Strava access token...") + info = strava_api.refresh_access_token( + client_id=self.client_id, + client_secret=self.client_secret, + refresh_token=self.refresh_token, + ) + self.access_token = info["access_token"] + self.refresh_token = info["refresh_token"] + self.expires_at = int(info["expires_at"]) + return True + except Exception as e: + self.logger.error(msg=f"Token refresh failed: {e}") + return bool( + self.access_token + and (not self.expires_at or time.time() < self.expires_at) + ) + + def upload_file( + self, + file_path: str, + name: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """Upload a CSV or FIT file to Strava. + + CSV files are converted to FIT first. + + Args: + file_path: Path to the CSV or FIT file. + name: Optional activity name. + description: Optional activity description. + + Returns: + Dictionary with 'success' and 'message' (and 'activity_id' on success). + """ + from .export_fit import FitExporter + + file_path_obj = Path(file_path) + if not file_path_obj.exists(): + return {"success": False, "message": f"File not found: {file_path_obj}"} + + if file_path_obj.suffix.lower() == ".csv": + self.logger.info(msg=f"Converting CSV to FIT: {file_path_obj.name}") + convert_result = FitExporter.convert_csv_to_fit(str(file_path_obj)) + if not convert_result["success"]: + return { + "success": False, + "message": convert_result.get("message", "Conversion failed"), + } + file_path_obj = Path(convert_result["file_path"]) + + if not self._ensure_fresh_token(): + return { + "success": False, + "message": "Strava token expired and refresh failed. Please reconnect.", + } + + try: + external_id = ( + file_path_obj.parent.name + if ( + file_path_obj.name == FIT_FILENAME + and file_path_obj.parent.name.startswith(WORKOUT_DIRECTORY_PREFIX) + ) + else file_path_obj.stem + ) + with open(file_path_obj, "rb") as f: + upload = strava_api.upload_activity( + access_token=self.access_token, + file_obj=f, + data_type="fit", + name=name, + description=description, + # Stable id lets Strava reject accidental re-uploads as duplicates. + external_id=external_id, + ) + + upload_id = upload.get("id") or upload.get("id_str") + if not upload_id: + raise strava_api.StravaApiError( + "Strava upload response did not include an upload id." + ) + + # Poll until Strava finishes processing (raises ActivityUploadError + # on a reported error, UploadTimeoutError if the poll window elapses). + activity_id = self._wait_for_upload(upload_id) + + try: + # Read the created activity back to confirm its id (mirrors the + # old Client.get_activity read-back). + activity = strava_api.get_activity(self.access_token, activity_id) + activity_id = activity.get("id", activity_id) + except strava_api.ActivityUploadError: + raise # genuine rejection - handled below + except Exception as e: + # Polling already assigned an activity id, but the final + # read-back failed - e.g. a token without activity:read scope, + # or Strava eventual consistency. The activity exists, so treat + # this as a successful upload. + if not activity_id: + raise + self.logger.warning( + msg=f"Activity {activity_id} created; read-back failed: {e}" + ) + + return { + "success": True, + "activity_id": activity_id, + "message": f"Upload successful! Activity ID: {activity_id}", + } + + except strava_api.ActivityUploadError as e: + return {"success": False, "message": f"Strava rejected the upload: {e}"} + except strava_api.UploadTimeoutError as e: + # Poll window elapsed or a stop was requested mid-poll; either way + # the upload was accepted and finishes processing server-side. + return { + "success": True, + "pending": True, + "activity_id": None, + "message": str(e), + } + except Exception as e: + return {"success": False, "message": f"Upload error: {e}"} + + def _wait_for_upload(self, upload_id: Any) -> Any: + """Poll an upload until Strava finishes processing it. + + Args: + upload_id: The id Strava returned from the initial upload POST. + + Returns: + The created ``activity_id`` once processing succeeds. + + Raises: + strava_api.ActivityUploadError: Strava reported a processing error. + strava_api.UploadTimeoutError: processing did not finish within + ``UPLOAD_WAIT_TIMEOUT`` seconds, or ``stop_event`` was set while + waiting (the upload keeps processing on Strava's side either way). + """ + deadline = time.time() + UPLOAD_WAIT_TIMEOUT + while True: + if self.stop_event is not None and self.stop_event.is_set(): + raise strava_api.UploadTimeoutError( + "Stop requested; upload accepted and still processing on " + "Strava. It should appear shortly." + ) + status = strava_api.get_upload(self.access_token, upload_id) + error = status.get("error") + if error: + raise strava_api.ActivityUploadError(str(error)) + activity_id = status.get("activity_id") + if activity_id: + return activity_id + if time.time() >= deadline: + raise strava_api.UploadTimeoutError( + "Upload accepted; still processing on Strava. " + "It should appear shortly." + ) + time.sleep(UPLOAD_POLL_INTERVAL) + + def get_updated_tokens(self) -> tuple[str, str, int]: + """Return current (access_token, refresh_token, expires_at).""" + return self.access_token, self.refresh_token, self.expires_at diff --git a/app/summary.py b/app/summary.py new file mode 100644 index 0000000..234bece --- /dev/null +++ b/app/summary.py @@ -0,0 +1,374 @@ +"""Post-workout summary computation. + +Turns a recorded workout (the Table produced by :class:`app.read_csv.CsvReader`) +into the numbers the summary screen shows: totals and averages, per-lap splits, +time in each heart-rate zone, and recorded sample series for the charts. + +Pure computation with no GUI dependencies, so it is unit-testable and reusable +(e.g. by a future trends view). Records, timer runs and lap groups come from the +same finalized workout model consumed by the FIT exporter, so the splits always +match the laps written to the FIT file, program-driven or freestyle alike. Time +in HR zones is accumulated over those same moving segments via +:func:`app.stats.time_in_hr_zones`; missing readings remain uncovered time so +the UI can show HR-signal coverage honestly. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from .coerce import to_float +from .finalized_workout import finalize_workout +from .logger import Logger +from .power import DEFAULT_PULL_LENGTH_M, estimate_power, normalize_pull_length +from .speed_series import recorded_speed_mps +from .stats import ( + time_in_hr_zones, + zone_lower_bounds, +) +from .table import Table +from .workout_metrics import SliceMetrics, compute_finalized_metrics + +logger = Logger.get_logger(name=__name__) + + +@dataclass +class LapSummary: + """Aggregates for one moving segment (one FIT lap).""" + + index: int # 1-based lap number + start_s: float # offset from the canonical recording start, seconds + active_time_s: float + elapsed_time_s: float + pause_after_s: float + distance_m: float + avg_speed_mps: float | None + max_speed_mps: float | None + avg_hr: float | None + max_hr: int | None + avg_power: float | None + max_power: int | None + avg_spm: float | None + max_spm: int | None + # Program step this lap came from (None for movement-detected laps), so a + # UI can label rest vs. work laps instead of anonymous numbers. + kind: str | None = None # warmup | work | rest | cooldown | effort + label: str | None = None # e.g. "Work 10:00" + + @property + def pause_time_s(self) -> float: + """Inactive time inside this lap, excluding the following gap.""" + return max(self.elapsed_time_s - self.active_time_s, 0.0) + + +@dataclass +class WorkoutSummary: + """Everything the summary screen needs, computed once from the CSV.""" + + start_time: datetime | None + active_time_s: float + elapsed_time_s: float + pause_time_s: float + distance_m: float + avg_speed_mps: float | None # over moving time + max_speed_mps: float | None + avg_hr: float | None + max_hr: int | None + avg_power: float | None + max_power: int | None + avg_spm: float | None + max_spm: int | None + zone_seconds: list[float] = field(default_factory=lambda: [0.0] * 5) + laps: list[LapSummary] = field(default_factory=list) + # Chart series, one entry per active sensor record emitted to FIT. Synthetic + # GPS start anchors are export-only compatibility records and do not appear. + # ``series_t`` is seconds since recording elapsed zero; the value + # series contain None for gaps so a chart can break the line instead of + # interpolating across pauses. ``series_distance`` is the cumulative + # distance (metres) at each record, an alternative x-axis for the same + # charts; unlike ``series_t`` it can contain None where a distance reading + # was missing (an empty CSV cell). + series_t: list[float] = field(default_factory=list) + series_speed: list[float | None] = field(default_factory=list) # m/s + series_hr: list[float | None] = field(default_factory=list) # bpm + series_power: list[float | None] = field(default_factory=list) # W + series_spm: list[float | None] = field(default_factory=list) # strokes/min + series_pull: list[float | None] = field(default_factory=list) # N + series_distance: list[float | None] = field(default_factory=list) # m + # True on the first record of each finalized lap or meaningful pause. The + # chart uses these boundaries to break lines without inventing non-FIT + # records solely as visual sentinels. + series_breaks: list[bool] = field(default_factory=list) + # Run-start boundary for each point: the elapsed time (seconds) and the + # cumulative active distance (metres) at the START of that record's window. + # At a break these are the run's start coordinates, so the chart can begin a + # line at the run boundary — the FIT timer START and the distance preceding + # its first sample window — instead of one window in. This mirrors the + # metric-free FIT run-start anchor (D009) so the drawn span matches the lap + # table (D020). ``series_anchor_distance`` is None where distance is missing; + # non-break entries are unused by the chart. + series_anchor_t: list[float] = field(default_factory=list) + series_anchor_distance: list[float | None] = field(default_factory=list) + # Planned step bands on the chart's canonical session-elapsed time axis. + # seconds, label and step kind. Empty for free workouts. + series_steps: list[tuple[float, float, str, str | None]] = field( + default_factory=list + ) + series_time_bounds: tuple[float, float] = (0.0, 0.0) + +def _lap_summary( + index: int, + start_time_ms: int, + end_time_ms: int, + timer_time_ms: int, + distance_m: float, + t0_ms: int, + metrics: SliceMetrics, + pause_after_ms: int = 0, + kind: str | None = None, + label: str | None = None, +) -> LapSummary: + active_time_s = timer_time_ms / 1000.0 + elapsed_time_s = max((end_time_ms - start_time_ms) / 1000.0, 0.0) + return LapSummary( + index=index, + start_s=(start_time_ms - t0_ms) / 1000.0, + active_time_s=active_time_s, + elapsed_time_s=elapsed_time_s, + pause_after_s=max(pause_after_ms / 1000.0, 0.0), + distance_m=distance_m, + avg_speed_mps=( + round(metrics.avg_speed, 3) + if metrics.avg_speed is not None + else None + ), + max_speed_mps=( + round(metrics.max_speed, 3) + if metrics.max_speed is not None + else None + ), + avg_hr=( + float(round(metrics.avg_heart_rate)) + if metrics.avg_heart_rate is not None + else None + ), + max_hr=(round(metrics.max_heart_rate) if metrics.max_heart_rate is not None else None), + avg_power=( + float(round(metrics.avg_power)) if metrics.avg_power is not None else None + ), + max_power=(round(metrics.max_power) if metrics.max_power is not None else None), + avg_spm=( + float(round(metrics.avg_cadence)) + if metrics.avg_cadence is not None + else None + ), + max_spm=(round(metrics.max_cadence) if metrics.max_cadence is not None else None), + kind=kind, + label=label, + ) + + +def _window_start_distance_m( + cumulative_m: float | None, credited_m: float | None +) -> float | None: + """Cumulative active distance at the start of a record's sample window. + + The endpoint less the metres credited to this record. Returns None when + either value is missing, so the chart simply omits the run-start anchor + rather than inventing a distance. + """ + if cumulative_m is None or credited_m is None: + return None + return cumulative_m - credited_m + + +def compute_summary( + table: Table, + max_hr: Any = 185, + zones: list[int] | None = None, + program_steps: list[dict[str, Any]] | None = None, + pull_length_m: Any = DEFAULT_PULL_LENGTH_M, +) -> WorkoutSummary: + """Compute a full workout summary from a workout Table. + + Args: + table: Output of ``CsvReader.read_all`` (including timestamp, heart + rate, instantaneous cadence and pull force, distance, and speed). + max_hr: Max heart rate used to derive zone bounds (when ``zones`` is None). + zones: Optional five ascending manual zone lower bounds (bpm). + program_steps: Optional recorded program step timeline (the + ``steps.json`` timeline; see ``segmentation.load_program_steps``). + When present, laps follow the program steps exactly as the FIT + exporter's do; otherwise laps come from movement detection. + pull_length_m: Recorded effective pull length used for estimated power. + + Raises: + ValueError: If the table is empty. + """ + if table.is_empty(): + raise ValueError("Workout contains no records.") + + finalized = finalize_workout(table, program_steps=program_steps, logger=logger) + if not finalized.segments: + raise ValueError("Workout contains no active ergometer samples.") + pull_length_m = normalize_pull_length(pull_length_m) + hrs = [to_float(v) for v in table.get_column("heart_rate")] + cadences = [to_float(v) for v in table.get_column("cadence_instant")] + forces = [to_float(v) for v in table.get_column("pull_force_instant")] + + # ---- chart series ------------------------------------------------------ + # Charts, lap/session aggregates and FIT records retain instantaneous + # sensor points. Each power point is derived from the instantaneous cadence + # and pull force in that packet. The renderer applies no moving average. + series_speed_by_index = recorded_speed_mps(table, finalized) + series_speed = [series_speed_by_index.get(i) for i in range(table.height)] + series_hr = [h if h is not None and h > 0 else None for h in hrs] + series_power = [ + (lambda p: float(p) if p is not None and p > 0 else None)( + estimate_power(f, c, pull_length_m) + ) + for f, c in zip(forces, cadences, strict=False) + ] + series_spm = [c if c is not None and c > 0 else None for c in cadences] + series_pull = [f if f is not None and f > 0 else None for f in forces] + + # ---- laps (identical to the FIT export, by construction) ---------------- + # The single shared finalized model: program-step groups when a timeline + # exists, or natural free laps separated by meaningful pauses otherwise. + segments = finalized.segments + finalized_laps = finalized.laps + finalized_metrics = compute_finalized_metrics( + table, finalized, pull_length_m=pull_length_m + ) + summary_start_ms = finalized.timeline_start_time_ms + laps = [] + for i, (lap, metrics) in enumerate( + zip(finalized_laps, finalized_metrics.laps, strict=True) + ): + next_start_ms = ( + finalized_laps[i + 1].start_time_ms + if i + 1 < len(finalized_laps) + else finalized.timeline_end_time_ms + ) + laps.append( + _lap_summary( + i + 1, + lap.start_time_ms, + lap.end_time_ms, + lap.timer_time_ms, + lap.distance_m, + summary_start_ms, + metrics, + pause_after_ms=max((next_start_ms or lap.end_time_ms) - lap.end_time_ms, 0), + kind=lap.kind, + label=lap.label, + ) + ) + + # ---- totals -------------------------------------------------------------- + active_time_s = finalized.total_timer_time_ms / 1000.0 + elapsed_time_s = finalized.total_elapsed_time_ms / 1000.0 + pause_time_s = finalized.total_pause_time_ms / 1000.0 + distance_m = finalized.total_distance_m + moving_indices = finalized.moving_indices + chart_breaks = set(finalized.chart_break_indices) + chart_start_ms = finalized.timeline_start_time_ms + # Overall averages/peaks come from the finalized aggregate shared verbatim + # with FIT session/lap messages. + # + # avg_power is the represented-time average of the instantaneous-point + # force/cadence estimates. max_power is the highest recorded-point estimate; + # a sub-second within-stroke peak is not available in the recorded channel. + totals = finalized_metrics.session + + # ---- time in HR zones ---------------------------------------------------- + # Accumulated over the same moving segments as the laps (shared + # app.stats.time_in_hr_zones). Missing readings deliberately remain + # uncovered so the zone bar can display an explicit no-signal share. + zone_seconds = time_in_hr_zones( + heart_rate=hrs, + segments=segments, + bounds=zone_lower_bounds(max_hr=max_hr, zones=zones), + sample_weights=[ + finalized.record_duration_s.get(i, 0.0) for i in range(table.height) + ], + ) + + return WorkoutSummary( + start_time=datetime.fromtimestamp(summary_start_ms / 1000.0), + active_time_s=active_time_s, + elapsed_time_s=elapsed_time_s, + pause_time_s=pause_time_s, + distance_m=distance_m, + avg_speed_mps=( + round(totals.avg_speed, 3) if totals.avg_speed is not None else None + ), + max_speed_mps=( + round(totals.max_speed, 3) if totals.max_speed is not None else None + ), + avg_hr=( + float(round(totals.avg_heart_rate)) + if totals.avg_heart_rate is not None + else None + ), + max_hr=(round(totals.max_heart_rate) if totals.max_heart_rate is not None else None), + avg_power=( + float(round(totals.avg_power)) if totals.avg_power is not None else None + ), + max_power=(round(totals.max_power) if totals.max_power is not None else None), + avg_spm=( + float(round(totals.avg_cadence)) + if totals.avg_cadence is not None + else None + ), + max_spm=( + round(totals.max_cadence) + if totals.max_cadence is not None + else None + ), + zone_seconds=zone_seconds, + laps=laps, + # Place each value at the exact CSV/FIT sample endpoint so a hover time + # maps one-to-one to the exported sensor record. Explicit bounds retain + # the complete elapsed timeline around those points. + series_t=[ + (finalized.timestamps_ms[i] - chart_start_ms) / 1000.0 + for i in moving_indices + ], + series_speed=[series_speed[i] for i in moving_indices], + series_hr=[series_hr[i] for i in moving_indices], + series_power=[series_power[i] for i in moving_indices], + series_spm=[series_spm[i] for i in moving_indices], + series_pull=[series_pull[i] for i in moving_indices], + series_distance=[ + finalized.record_cumulative_distance_m.get(i) for i in moving_indices + ], + series_breaks=[i in chart_breaks for i in moving_indices], + # Start-of-window coordinates: the FIT timer START (window start time) + # and the cumulative distance preceding the window (endpoint minus the + # metres credited to this record). At a run/lap break these are the + # run's start boundary, letting the chart draw the first sample window + # instead of dropping it (D020). + series_anchor_t=[ + (finalized.record_windows_ms[i][0] - chart_start_ms) / 1000.0 + for i in moving_indices + ], + series_anchor_distance=[ + _window_start_distance_m( + finalized.record_cumulative_distance_m.get(i), + finalized.record_distance_m.get(i), + ) + for i in moving_indices + ], + series_steps=[ + ( + max((lap.start_time_ms - chart_start_ms) / 1000.0, 0.0), + max((lap.end_time_ms - chart_start_ms) / 1000.0, 0.0), + str(lap.label or lap.kind or "Step"), + lap.kind, + ) + for lap in finalized_laps + if lap.label or lap.kind + ], + series_time_bounds=(0.0, elapsed_time_s), + ) diff --git a/app/table.py b/app/table.py new file mode 100644 index 0000000..bed9994 --- /dev/null +++ b/app/table.py @@ -0,0 +1,72 @@ +"""Minimal columnar table backed by plain Python lists. + +This provides only the column and row access needed by workout processing, +without adding a DataFrame dependency to the packaged application. +""" + +from collections.abc import Sequence +from decimal import Decimal +from typing import Any + + +class Table: + """An ordered set of rows, each a ``{column: value}`` dict.""" + + def __init__(self, columns: Sequence[str], rows: list[dict[str, Any]]) -> None: + self._columns = list(columns) + self._column_set = frozenset(self._columns) # O(1) membership tests + self._rows = rows + + # ---- shape ------------------------------------------------------------- + @property + def height(self) -> int: + """Return the number of rows.""" + return len(self._rows) + + @property + def columns(self) -> list[str]: + """Return the column names in order.""" + return list(self._columns) + + def is_empty(self) -> bool: + """Return True if the table has no rows.""" + return not self._rows + + def __contains__(self, column: str) -> bool: # enables `"x" in table` + return column in self._column_set + + # ---- access ------------------------------------------------------------ + def get_column(self, name: str) -> list[Any]: + """Return all values for a column (missing column -> all None).""" + return [row.get(name) for row in self._rows] + + def get_column_or_none(self, name: str) -> list[Any] | None: + """Return a column's values, or None when the column doesn't exist. + + The common "optional column" pattern (``get_column(x) if x in table + else None``) in one place: callers that treat a missing column as "no + data at all" (rather than a column of Nones) use this. + """ + return self.get_column(name) if name in self._column_set else None + + def row(self, index: int) -> dict[str, Any]: + """Return a single row as a dict (supports negative indices).""" + return dict(self._rows[index]) + +def parse_value(raw: str, kind: str) -> Any | None: + """Parse a CSV cell to the configured type (``"int"`` or ``"decimal"``). + + Empty strings and the literal ``"None"`` become None, matching how the CSV + was written for absent readings. + + Raises: + ValueError: If a non-empty value cannot be parsed as the given type. + """ + if raw is None: + return None + text = raw.strip() + if text == "" or text == "None": + return None + if kind == "decimal": + return Decimal(text) + return int(text) diff --git a/app/worker_result.py b/app/worker_result.py new file mode 100644 index 0000000..9380ad5 --- /dev/null +++ b/app/worker_result.py @@ -0,0 +1,40 @@ +"""Typed terminal outcomes shared by background workers and the GUI.""" + +from dataclasses import asdict, dataclass +from typing import Literal, cast + +from .events import TerminalEventType, UiEvent + +WorkerOutcome = Literal["success", "cancelled", "partial", "failed", "pending"] + + +@dataclass(frozen=True) +class WorkerResult: + """Exactly one terminal description of a worker operation.""" + + outcome: WorkerOutcome + stage: str + message: str + csv_path: str | None = None + fit_path: str | None = None + durable_rows: int = 0 + retryable: bool = False + activity_id: int | None = None + upload_id: int | None = None + tokens_updated: bool = False + new_access_token: str | None = None + new_refresh_token: str | None = None + + @property + def success(self) -> bool: + """Whether the requested operation completed successfully.""" + return self.outcome == "success" + + def to_event(self, event_type: TerminalEventType) -> UiEvent: + """Return a GUI-event dictionary without duplicating result mapping.""" + return cast(UiEvent, { + "type": event_type, + **asdict(self), + "success": self.success, + "pending": self.outcome == "pending", + }) diff --git a/app/workout_metadata.py b/app/workout_metadata.py new file mode 100644 index 0000000..8608773 --- /dev/null +++ b/app/workout_metadata.py @@ -0,0 +1,106 @@ +"""Versioned processing metadata stored in each workout directory.""" + +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .atomic_json import write_atomic_json +from .workout_paths import WorkoutPaths + +PROCESSING_CONFIG_KEYS = ( + "pull_length_m", + "max_hr", + "hr_zone_mode", + "hr_zones", +) +METADATA_VERSION = 1 + + +def _validate_processing_config(saved: dict[str, Any]) -> None: + """Validate the complete v1 processing-metadata schema.""" + try: + pull_length = float(saved["pull_length_m"]) + max_hr = float(saved["max_hr"]) + except (TypeError, ValueError) as exc: + raise ValueError("Workout is not v1: invalid numeric processing metadata") from exc + if not 0.3 <= pull_length <= 1.2: + raise ValueError("Workout is not v1: pull_length_m is out of range") + if max_hr <= 0: + raise ValueError("Workout is not v1: invalid maximum heart rate") + zone_mode = saved["hr_zone_mode"] + zones = saved["hr_zones"] + if zone_mode not in ("auto", "manual") or not isinstance(zones, list): + raise ValueError("Workout is not v1: invalid heart-rate zone configuration") + if zone_mode == "manual": + try: + zone_values = [int(value) for value in zones] + except (TypeError, ValueError) as exc: + raise ValueError("Workout is not v1: invalid manual HR zones") from exc + if len(zone_values) != 5 or zone_values != sorted(set(zone_values)): + raise ValueError("Workout is not v1: manual HR zones must be ascending") + + +def metadata_path(csv_path: str | Path) -> Path: + """Return the fixed metadata path for a workout CSV.""" + return WorkoutPaths.from_csv(csv_path).metadata + + +def save_workout_metadata( + csv_path: str | Path, + config: Mapping[str, Any], + workout_mode: str, +) -> None: + """Atomically save the processing inputs needed to reproduce a FIT.""" + if workout_mode not in ("free", "planned"): + raise ValueError("workout_mode must be free or planned") + missing = [key for key in PROCESSING_CONFIG_KEYS if key not in config] + if missing: + raise ValueError(f"Cannot save v1 metadata: configuration missing {missing}") + path = metadata_path(csv_path) + payload = { + "version": METADATA_VERSION, + "workout_mode": workout_mode, + "processing_config": { + key: config[key] for key in PROCESSING_CONFIG_KEYS + }, + } + write_atomic_json(path, payload) + + +def load_workout_metadata(csv_path: str | Path) -> dict[str, Any]: + """Load and validate a v1 workout metadata sidecar.""" + try: + with open(metadata_path(csv_path), encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, ValueError) as exc: + raise ValueError("Workout is not v1: valid metadata is required") from exc + if ( + not isinstance(payload, dict) + or set(payload) != {"version", "workout_mode", "processing_config"} + or payload.get("version") != METADATA_VERSION + ): + raise ValueError("Workout is not v1: invalid metadata schema or version") + if payload.get("workout_mode") not in ("free", "planned"): + raise ValueError("Workout is not v1: workout_mode must be free or planned") + saved = payload.get("processing_config") + if not isinstance(saved, dict): + raise ValueError("Workout is not v1: processing_config is required") + expected_keys = set(PROCESSING_CONFIG_KEYS) + if set(saved) != expected_keys: + missing = sorted(expected_keys - set(saved)) + extra = sorted(set(saved) - expected_keys) + raise ValueError( + f"Workout is not v1: processing_config schema mismatch " + f"(missing={missing}, extra={extra})" + ) + _validate_processing_config(saved) + return payload + + +def load_workout_config(csv_path: str | Path) -> dict[str, Any]: + """Load and validate the exact v1 processing configuration.""" + payload = load_workout_metadata(csv_path) + saved = payload.get("processing_config") + assert isinstance(saved, dict) + return dict(saved) diff --git a/app/workout_metrics.py b/app/workout_metrics.py new file mode 100644 index 0000000..21d1d4e --- /dev/null +++ b/app/workout_metrics.py @@ -0,0 +1,110 @@ +"""Final session and lap metrics shared by the summary and FIT exporter. + +The finalized workout owns the activity domain (records, timer windows, laps +and distance). This module is the corresponding aggregate boundary: it turns +that domain into one immutable set of session/lap statistics which every +consumer uses verbatim. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .finalized_workout import FinalizedLap, FinalizedWorkout +from .power import DEFAULT_PULL_LENGTH_M, normalize_pull_length +from .stats import aggregate_metrics +from .table import Table + + +@dataclass(frozen=True) +class SliceMetrics: + """Authoritative aggregates for one session or lap.""" + + avg_heart_rate: float | None + max_heart_rate: float | None + avg_cadence: float | None + max_cadence: float | None + avg_power: float | None + max_power: float | None + avg_speed: float | None + max_speed: float | None + + +@dataclass(frozen=True) +class FinalizedMetrics: + """One session aggregate and its positionally matching lap aggregates.""" + + session: SliceMetrics + laps: tuple[SliceMetrics, ...] + + +def _values(table: Table, column: str, indices: tuple[int, ...]) -> list[Any]: + values = table.get_column(column) + return [values[index] for index in indices] + + +def _slice_metrics( + table: Table, + indices: tuple[int, ...], + weights_s: tuple[float, ...], + pull_length_m: float, +) -> SliceMetrics: + raw = aggregate_metrics( + heart_rate=_values(table, "heart_rate", indices), + cadence=_values(table, "cadence_instant", indices), + power_cadence=_values(table, "cadence_instant", indices), + power_pull_force=_values(table, "pull_force_instant", indices), + speed=_values(table, "speed_instant", indices), + sample_weights=weights_s, + pull_length_m=pull_length_m, + ) + return SliceMetrics( + avg_heart_rate=raw["avg_heart_rate"], + max_heart_rate=raw["max_heart_rate"], + avg_cadence=raw["avg_cadence"], + max_cadence=raw["max_cadence"], + avg_power=raw["avg_power"], + max_power=raw["max_power"], + avg_speed=raw["avg_speed"], + max_speed=raw["max_speed"], + ) + + +def _lap_metrics( + table: Table, + lap: FinalizedLap, + pull_length_m: float, +) -> SliceMetrics: + return _slice_metrics( + table, + tuple(lap.record_indices), + tuple(lap.record_weights_s), + pull_length_m, + ) + + +def compute_finalized_metrics( + table: Table, + finalized: FinalizedWorkout, + pull_length_m: Any = DEFAULT_PULL_LENGTH_M, +) -> FinalizedMetrics: + """Compute the single aggregate result used by summary and FIT output. + + Average speed is the represented-time-weighted mean of active + ``speed_instant`` samples, cadence uses ``cadence_instant``, and power is + estimated from ``cadence_instant`` and ``pull_force_instant``. Distance is + aggregated independently from the cumulative odometer. + """ + length = normalize_pull_length(pull_length_m) + laps = tuple( + _lap_metrics(table, lap, length) for lap in finalized.laps + ) + moving_indices = tuple(finalized.moving_indices) + session = _slice_metrics( + table, + moving_indices, + tuple(finalized.record_duration_s[index] for index in moving_indices), + length, + ) + return FinalizedMetrics(session=session, laps=laps) diff --git a/app/workout_paths.py b/app/workout_paths.py new file mode 100644 index 0000000..f80c655 --- /dev/null +++ b/app/workout_paths.py @@ -0,0 +1,91 @@ +"""The fixed on-disk layout for one KayakFit workout. + +Every workout owns a timestamped directory below ``workouts/``. Keeping +all of its artifacts together makes it safe to move, inspect and discard a +single workout without relying on filename prefixes scattered across modules. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +WORKOUT_DIRECTORY_PREFIX = "workout_" +CSV_FILENAME = "workout.csv" +METADATA_FILENAME = "metadata.json" +STEPS_FILENAME = "steps.json" +FIT_FILENAME = "activity.fit" + + +@dataclass(frozen=True) +class WorkoutPaths: + """Known artifact paths inside one workout directory.""" + + directory: Path + + @property + def csv(self) -> Path: + return self.directory / CSV_FILENAME + + @property + def metadata(self) -> Path: + return self.directory / METADATA_FILENAME + + @property + def steps(self) -> Path: + return self.directory / STEPS_FILENAME + + @property + def fit(self) -> Path: + return self.directory / FIT_FILENAME + + @property + def fit_temp(self) -> Path: + return self.directory / f"{FIT_FILENAME}.tmp" + + @classmethod + def from_csv(cls, csv_path: str | Path) -> WorkoutPaths: + """Resolve the fixed artifact paths beside a workout CSV.""" + return cls(Path(csv_path).parent) + + @classmethod + def reserve(cls, workouts_root: Path, started_at: datetime) -> WorkoutPaths: + """Atomically reserve a unique directory for a new workout. + + ``mkdir`` is the reservation operation: if another process has already + claimed the timestamp, try a deterministic numeric suffix instead. + """ + year_dir = workouts_root / started_at.strftime("%Y") + year_dir.mkdir(parents=True, exist_ok=True) + timestamp = started_at.strftime("%Y%m%d_%H%M%S") + for suffix in range(1000): + name = ( + f"{WORKOUT_DIRECTORY_PREFIX}{timestamp}" + if suffix == 0 + else f"{WORKOUT_DIRECTORY_PREFIX}{timestamp}_{suffix:03d}" + ) + candidate = cls(year_dir / name) + try: + candidate.directory.mkdir() + return candidate + except FileExistsError: + continue + raise FileExistsError( + f"Could not reserve a unique workout directory for {timestamp}" + ) + + def is_owned_by(self, workouts_root: str | Path) -> bool: + """Whether this is a direct, owned workout directory beneath the root.""" + try: + root = Path(workouts_root).resolve() + directory = self.directory.resolve() + relative = directory.relative_to(root) + except (OSError, ValueError): + return False + return ( + len(relative.parts) == 2 + and len(relative.parts[0]) == 4 + and relative.parts[0].isdigit() + and directory.name.startswith(WORKOUT_DIRECTORY_PREFIX) + ) diff --git a/app/workout_session.py b/app/workout_session.py new file mode 100644 index 0000000..21fa45f --- /dev/null +++ b/app/workout_session.py @@ -0,0 +1,959 @@ +"""Live workout recording orchestrator. + +Coordinates a complete session: connects the ergometer and (optionally) a +heart-rate monitor over BLE, merges their async callbacks into sensor-sample rows +written straight to CSV, and feeds the live UI through data/status callbacks. +Runs the shared ``LiveSegmenter`` for real-time autopause/lap indication, an +optional ``ProgramRunner`` for structured workouts (recording each step's real +start/end boundary and writing ``steps.json`` in the workout directory at the +end), a data-stall watchdog, and an inactivity auto-stop. Teardown +is guarded step by step so a recording always reaches disk even when a device +errors on the way down. +""" + +import asyncio +import contextlib +import time +import traceback +from collections.abc import Callable +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any, cast + +from bleak import BleakScanner +from bleak.backends.device import BLEDevice + +from . import recovery +from .atomic_json import write_atomic_json +from .coerce import to_float +from .config import get_bool +from .events import StatusPayload +from .heart_rate_monitor_bluetooth import HeartRateMonitor +from .kayakfirst_ergometer_bluetooth import ( + ErgometerConnectionError, + KayakFirstErgometer, +) +from .logger import Logger +from .program import Program +from .program_runner import ProgramRunner +from .read_csv import CsvReader +from .recording_timeline import RecordingTimeline +from .segmentation import ( + LiveSegmenter, + load_program_steps, +) +from .summary import compute_summary +from .table import Table +from .worker_result import WorkerResult +from .workout_metadata import save_workout_metadata +from .workout_paths import WorkoutPaths +from .write_csv import CsvWriteError, CsvWriter + +_HRM_DISCOVERY_ATTEMPTS = 2 +_PROGRAM_CHECKPOINT_INTERVAL_S = 15.0 +_HRM_DISCOVERY_RETRY_DELAY_S = 1.0 + + +class WorkoutAbortedError(Exception): + """Raised to cancel a workout before it starts. + + For example, the user declined to continue after a heart-rate monitor + could not be reached. + """ + + +class WorkoutSession: + """Manages a complete workout session with data collection.""" + + def __init__( + self, + config: dict[str, Any], + boat_weight: int | None = None, + person_weight: int | None = None, + log_callback: Callable[[str], None] | None = None, + data_callback: Callable[[dict[str, Any]], None] | None = None, + status_callback: Callable[[StatusPayload], None] | None = None, + prompt_callback: Callable[[str, str], bool] | None = None, + program: Program | None = None, + advance_event: Any | None = None, + ) -> None: + """Initialize workout session. + + Args: + config: Configuration dictionary from YAML. + boat_weight: Override boat weight (kg), uses config default if None. + person_weight: Override person weight (kg), uses config default if None. + log_callback: Optional callback function for logging. + data_callback: Optional callback receiving each sensor-sample dictionary + (for a live UI readout). + status_callback: Optional callback receiving connection/session status + events (dicts with an ``event`` key). + prompt_callback: Optional blocking callback ``(title, message) -> bool`` + used to ask the user a yes/no question (e.g. whether to continue + without a heart-rate monitor). Returns True to proceed. + program: Optional structured training program to drive live off + elapsed time / distance; None runs a free-training workout. + advance_event: Optional event the GUI sets to manually advance the + current program step (open/manual steps, or to skip ahead). + """ + self.config = config + self.data_callback = data_callback + self.status_callback = status_callback + self.prompt_callback = prompt_callback + self._streaming_announced = False + + # Brief inactive flags are debounced so the live pause state and + # free-lap count match finalized natural laps at low cadence. + self.segmenter = LiveSegmenter() + + # Optional structured training program driven off elapsed time / distance. + self.program_runner: ProgramRunner | None = ( + ProgramRunner(program) if program is not None else None + ) + # Set by the GUI to manually advance the current program step (used for + # open/manual steps, or to skip ahead). + self.advance_event = advance_event + + # Program step timeline (real-time boundaries), written next to the CSV + # so the FIT exporter can build one lap per step. + self._program_step_log: list[dict[str, Any]] = [] + self._current_step_entry: dict[str, Any] | None = None + self._last_session_elapsed_s: float | None = None + self._last_program_checkpoint_monotonic = 0.0 + + if boat_weight is not None: + self.config["boat_weight_default"] = boat_weight + if person_weight is not None: + self.config["person_weight_default"] = person_weight + + self.ergometer: KayakFirstErgometer | None = None + self.hrm: HeartRateMonitor | None = None + + self.session_start: datetime | None = None + self._recording_timeline: RecordingTimeline | None = None + self.stop_event = asyncio.Event() + self.csv_writer: CsvWriter | None = None + self.csv_path: str | None = None + self._persistence_error: str | None = None + self._summary_error: str | None = None + + # Data-stall watchdog: warn if the ergometer is connected but stops + # sending packets (e.g. firmware hang) so the gap isn't silent. + self._last_data_monotonic: float | None = None + self._stalled = False + self.data_stall_timeout: float = float(self.config.get("data_stall_timeout", 8)) + + # Inactivity auto-stop: end (and save) the workout after this many + # minutes without any forward movement, so a session left running does + # not become a multi-hour activity. 0 disables it. + self.inactivity_autostop_s: float = ( + float(self.config.get("inactivity_autostop_minutes", 30)) * 60.0 + ) + self._last_movement_monotonic: float | None = None + self._auto_stopped_inactive = False + + self.latest_heart_rate: int | None = None + self.latest_heart_rate_time: float | None = None + # Heart-rate readings older than this (seconds) are treated as stale and + # not attached to ergometer rows, so a dropped strap stops polluting the + # CSV with a frozen value. 0 disables expiry. + self.hrm_stale_timeout: float = float( + self.config.get("hrm_stale_timeout", 10) + ) + + self.data_point_count = 0 + + # (Re)attach the GUI activity-log callback; process signal handling is + # owned by the entrypoint (kayakfit_gui.main), not by logging setup. + Logger.setup( + log_level=self.config.get("log_level", "INFO"), + log_callback=log_callback, + ) + self.logger = Logger.get_logger(name=__name__) + + def _emit_status(self, event: str, device: str, **payload: Any) -> None: + """Send a status event to the UI if a status callback is registered.""" + if self.status_callback: + # Never let UI plumbing break the session. + with contextlib.suppress(Exception): + self.status_callback( + cast(StatusPayload, {"event": event, "device": device, **payload}) + ) + + def _emit_program_status(self, state: dict[str, Any]) -> None: + """Marshal a program state (with Step objects) into a UI-safe event.""" + step = state.get("step") + nxt = state.get("next") + self._emit_status( + event="program", + device="session", + transition=state.get("transition"), + step_index=int(state.get("step_index", 0)), + total=int(state.get("total", 0)), + step_label=step.label if step is not None else None, + step_kind=step.kind if step is not None else None, + target=step.target if step is not None else None, + remaining=state.get("remaining"), + remaining_kind=state.get("remaining_kind"), + fraction=state.get("fraction"), + next_label=nxt.label if nxt is not None else None, + done=bool(state.get("done")), + ) + + def _record_step_boundary(self, state: dict[str, Any]) -> None: + """Record canonical program step bounds for the FIT lap timeline.""" + if state.get("transition") not in ("step_start", "program_complete"): + return + boundary_elapsed = to_float(state.get("boundary_elapsed")) + if boundary_elapsed is None: + raise ValueError("Program transition is missing canonical elapsed time") + if self._current_step_entry is not None: + self._current_step_entry["end_elapsed_s"] = boundary_elapsed + self._program_step_log.append(self._current_step_entry) + self._current_step_entry = None + step = state.get("step") + if step is not None: # None on program_complete + self._current_step_entry = { + "index": int(state.get("step_index", 0)), + "type": getattr(step, "kind", ""), + "label": getattr(step, "label", ""), + "start_elapsed_s": boundary_elapsed, + } + + @staticmethod + def _program_transition_events(state: dict[str, Any]) -> list[dict[str, Any]]: + """Return ordered transition snapshots from a runner result.""" + transitions = state.get("transitions") + if isinstance(transitions, list): + return [item for item in transitions if isinstance(item, dict)] + return [state] if state.get("transition") is not None else [] + + def _write_program_steps(self, close_open: bool = True) -> None: + """Durably write the performed program timeline next to the CSV.""" + if self.program_runner is None or not self.csv_path: + return + # Close an open step (workout stopped before the program finished). + if ( + close_open + and self._current_step_entry is not None + and self._last_session_elapsed_s is not None + ): + self._current_step_entry["end_elapsed_s"] = self._last_session_elapsed_s + self._program_step_log.append(self._current_step_entry) + self._current_step_entry = None + steps = list(self._program_step_log) + if ( + self._current_step_entry is not None + and self._last_session_elapsed_s is not None + ): + current = dict(self._current_step_entry) + current["end_elapsed_s"] = self._last_session_elapsed_s + steps.append(current) + if not steps: + return + if self.program_runner.done and self._last_session_elapsed_s is not None: + steps[-1] = { + **steps[-1], + "end_elapsed_s": self._last_session_elapsed_s, + } + sidecar = WorkoutPaths.from_csv(self.csv_path).steps + write_atomic_json( + sidecar, + { + "version": 1, + "complete": bool(self.program_runner.done), + "steps": steps, + }, + ) + self.logger.info(msg=f"Program step timeline saved to {sidecar.name}") + + def _persist_program_steps( + self, close_open: bool = True, *, force: bool = True + ) -> None: + """Persist steps or stop with a visible partial-recording result.""" + now = time.monotonic() + if ( + not force + and now - self._last_program_checkpoint_monotonic + < _PROGRAM_CHECKPOINT_INTERVAL_S + ): + return + try: + self._write_program_steps(close_open=close_open) + self._last_program_checkpoint_monotonic = now + except OSError as exc: + self._report_persistence_error( + f"Could not save planned-workout timeline: {exc}" + ) + + def _hrm_enabled(self) -> bool: + """Whether a heart-rate monitor should be used at all. + + Disabled ("no") means the session never tries to connect an HRM and + never asks "continue without HRM?" — for users without a strap. + """ + return get_bool(self.config, "hrm_enabled", default=True) + + def _ble_auto_reconnect(self) -> bool: + """Whether BLE devices should auto-reconnect after a mid-session drop.""" + return get_bool(self.config, "ble_auto_reconnect", default=True) + + async def _run_until_stop(self, coro: Any, what: str) -> Any: + """Await *coro*, aborting immediately when the user presses Stop. + + BLE connect/initialize can spend the better part of a minute in + retries, timeouts and backoff sleeps. Racing that work against + ``stop_event`` keeps the Stop button responsive even while the + ergometer is unreachable — pressing Stop cancels the in-flight + attempt instead of waiting for the whole retry budget. + """ + work = asyncio.ensure_future(coro) + stop = asyncio.ensure_future(self.stop_event.wait()) + try: + done, _ = await asyncio.wait( + {work, stop}, return_when=asyncio.FIRST_COMPLETED + ) + if work in done: + return work.result() # re-raises the work's exception, if any + work.cancel() + with contextlib.suppress(BaseException): + await work + raise WorkoutAbortedError(f"Workout stopped while {what}.") + finally: + stop.cancel() + with contextlib.suppress(BaseException): + await stop + + async def run(self) -> WorkerResult: + """Main session execution.""" + failure_stage: str | None = None + failure_message = "" + cancelled = False + try: + await self._run_until_stop( + self._connect_devices(), "connecting to devices" + ) + + assert self.ergometer is not None, "Ergometer not connected" + await self._run_until_stop( + self.ergometer.initialize(), "initializing the ergometer" + ) + + self.session_start = datetime.now() + monotonic_start = time.monotonic() + self._recording_timeline = RecordingTimeline( + epoch_start_ms=round(self.session_start.timestamp() * 1000), + monotonic_start_s=monotonic_start, + ) + + self._setup_csv_writer() + + await self._run_until_stop( + self.ergometer.start_workout(), "starting the workout" + ) + + self.logger.info( + msg="Workout started! Press Stop button or Ctrl+C to end workout." + ) + + # Arm the watchdog now so a stream that never starts is also caught, + # not just one that stops mid-session. + self._last_data_monotonic = time.monotonic() + self._last_movement_monotonic = time.monotonic() + + poll_task = asyncio.create_task(coro=self.ergometer.poll_data()) + watchdog_task = asyncio.create_task(coro=self._watchdog()) + + await self.stop_event.wait() + + for task in (poll_task, watchdog_task): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + except WorkoutAbortedError as e: + self.logger.info(msg=f"\n{e}") + cancelled = True + failure_message = str(e) + + except ErgometerConnectionError as e: + self.logger.error(msg=f"\n✗ {e}") + self.logger.error(msg="Cannot start workout without ergometer connection") + failure_stage = "device_connection" + failure_message = str(e) + + except CsvWriteError as e: + self._report_persistence_error(str(e)) + failure_stage = "persistence" + failure_message = str(e) + + except Exception as e: + self.logger.error(msg=f"Unexpected workout failure: {e}") + failure_stage = "recording" + failure_message = str(e) + + finally: + # Every teardown step is guarded: a BLE error while disconnecting + # must never prevent the CSV from being closed and the summary + # (which drives auto-export) from being emitted. + self.logger.info(msg="\nStopping workout...") + + # disconnect() is called even when the link is down: it also + # cancels a running auto-reconnect loop, so Stop works while the + # device is unreachable instead of leaving retries running. + if self.ergometer: + try: + if self.ergometer.is_connected: + await self.ergometer.stop_workout() + await self.ergometer.disconnect() + self.logger.info(msg="✓ Disconnected from KayakFirst") + except Exception as e: + self.logger.error(msg=f"Error disconnecting ergometer: {e}") + + if self.hrm: + try: + await self.hrm.disconnect() + self.logger.info(msg="✓ Disconnected from HRM") + except Exception as e: + self.logger.error(msg=f"Error disconnecting HRM: {e}") + + if self.csv_writer: + try: + self.csv_path = self.csv_writer.close() + if self._persistence_error is None: + self.logger.info(msg=f"✓ Workout data saved to {self.csv_path}") + except CsvWriteError as e: + self._report_persistence_error(str(e)) + self.csv_path = self.csv_writer.output_path + except Exception as e: + self.logger.error(msg=f"Error closing CSV writer: {e}") + failure_stage = "persistence" + failure_message = str(e) + if self.csv_path: + self._persist_program_steps() + + if self.csv_path and self.data_point_count > 0: + try: + reader = CsvReader(file_path=self.csv_path) + workout_df = reader.read_all() + self._print_workout_summary(workout_df=workout_df) + if self._summary_error is not None: + failure_stage = "finalization" + failure_message = self._summary_error + # FIT file creation removed - handled by export worker + except Exception as e: + self.logger.error(msg=f"Error creating summary: {e}") + failure_stage = "finalization" + failure_message = str(e) + else: + # Nothing worth recovering; drop the active-workout marker. + try: + recovery.clear_active() + except OSError as e: + failure_stage = "persistence" + failure_message = f"Could not clear recovery state: {e}" + self.logger.info(msg="No workout data recorded") + + self.logger.info(msg="Session completed") + + csv_path = str(self.csv_path) if self.csv_path else None + if self._persistence_error is not None: + return WorkerResult( + outcome="partial" if self.data_point_count > 0 else "failed", + stage="persistence", + message=self._persistence_error, + csv_path=csv_path, + durable_rows=( + self.csv_writer.records_written if self.csv_writer else 0 + ), + retryable=True, + ) + if failure_stage is not None: + return WorkerResult( + outcome="partial" if self.data_point_count > 0 else "failed", + stage=failure_stage, + message=failure_message or "Workout failed", + csv_path=csv_path, + durable_rows=( + self.csv_writer.records_written if self.csv_writer else 0 + ), + retryable=True, + ) + if cancelled or self.data_point_count == 0: + return WorkerResult( + outcome="cancelled", + stage="recording", + message=failure_message or "Workout ended without recorded samples", + csv_path=csv_path, + ) + return WorkerResult( + outcome="success", + stage="recording", + message="Workout recorded successfully", + csv_path=csv_path, + durable_rows=self.csv_writer.records_written if self.csv_writer else 0, + ) + + def _setup_csv_writer(self) -> None: + """Setup CSV writer with output path.""" + base_dir = Path.home() / "KayakFit" / self.config.get("output_dir", "workouts") + session_start = self.session_start or datetime.now() + try: + paths = WorkoutPaths.reserve(base_dir, session_start) + except OSError as exc: + raise CsvWriteError(str(exc)) from exc + try: + self.csv_writer = CsvWriter(output_path=str(paths.csv)) + self.csv_path = str(paths.csv) + save_workout_metadata( + paths.csv, + self.config, + "planned" if self.program_runner is not None else "free", + ) + # Mark this workout active so an interrupted session can be recovered + # on the next launch. The GUI clears the marker once it is safely exported. + recovery.mark_active(paths.directory, started_at=session_start) + except (OSError, ValueError) as exc: + # The directory was reserved exclusively for this failed setup, so + # clean it up rather than leaving a misleading empty workout behind. + if self.csv_writer is not None: + with contextlib.suppress(Exception): + self.csv_writer.close() + with contextlib.suppress(OSError): + paths.csv.unlink() + with contextlib.suppress(OSError): + paths.metadata.unlink() + with contextlib.suppress(OSError): + paths.directory.rmdir() + self.csv_writer = None + self.csv_path = None + raise CsvWriteError(f"Could not initialize workout storage: {exc}") from exc + + async def _resolve_hrm_device(self, address: str) -> BLEDevice | None: + """Find the configured HRM before another BLE connection is active.""" + timeout = float(self.config.get("ble_connect_timeout", 15)) + for attempt in range(1, _HRM_DISCOVERY_ATTEMPTS + 1): + self.logger.info( + msg=f"Locating HRM at {address} " + f"(attempt {attempt}/{_HRM_DISCOVERY_ATTEMPTS})..." + ) + try: + device = await BleakScanner.find_device_by_address( + address, timeout=timeout + ) + except asyncio.CancelledError: + raise + except Exception as exc: + self.logger.warning( + msg=f"HRM discovery attempt " + f"{attempt}/{_HRM_DISCOVERY_ATTEMPTS} failed: {exc}" + ) + else: + if device is not None: + self.logger.info(msg=f"✓ Located HRM at {address}") + return device + self.logger.warning( + msg=f"HRM discovery attempt " + f"{attempt}/{_HRM_DISCOVERY_ATTEMPTS} did not find {address}" + ) + + if attempt < _HRM_DISCOVERY_ATTEMPTS: + await asyncio.sleep(_HRM_DISCOVERY_RETRY_DELAY_S) + + return None + + async def _connect_devices(self) -> None: + """Resolve the optional HRM, then connect the ergometer and HRM.""" + hrm_address = str(self.config.get("hrm_mac", "")).strip() + use_hrm = bool(hrm_address) and self._hrm_enabled() + resolved_hrm: BLEDevice | None = None + + # Resolving an address inside BleakClient.connect() triggers an implicit + # scan. Do that explicitly before the ergometer link is open: some macOS + # and Windows adapters intermittently miss the strap while another BLE + # connection is active. + if use_hrm: + self._emit_status(event="connecting", device="hrm") + resolved_hrm = await self._resolve_hrm_device(hrm_address) + if resolved_hrm is None: + self.logger.warning(msg=f"✗ Failed to locate HRM at {hrm_address}") + if not await self._confirm_continue_without_hrm(): + self._emit_status(event="absent", device="hrm") + raise WorkoutAbortedError( + "Workout cancelled: heart-rate monitor not reachable." + ) + self.logger.info(msg="Will continue without HRM") + self._emit_status(event="absent", device="hrm") + use_hrm = False + else: + self._emit_status(event="absent", device="hrm") + + self.logger.info( + msg=f"Connecting to KayakFirst at {self.config['ergometer_mac']}..." + ) + self._emit_status(event="connecting", device="ergometer") + + self.ergometer = KayakFirstErgometer( + address=self.config["ergometer_mac"], + person_weight=self.config["person_weight_default"], + boat_weight=self.config["boat_weight_default"], + display_config=self.config["display_config_numbers"], + log_level=self.config["log_level"], + connect_timeout=float(self.config.get("ble_connect_timeout", 15)), + connect_retries=int(self.config.get("ble_connect_retries", 3)), + auto_reconnect=self._ble_auto_reconnect(), + reconnect_delay=float(self.config.get("ble_reconnect_delay", 5)), + status_callback=lambda ev: self._emit_status(**ev), + ) + + await self.ergometer.connect(callback=self._on_ergometer_data) + self._emit_status(event="connected", device="ergometer") + + if use_hrm and resolved_hrm is not None: + try: + self.logger.info( + msg=f"Connecting to HRM at {hrm_address}..." + ) + self._emit_status(event="connecting", device="hrm") + self.hrm = HeartRateMonitor( + address=hrm_address, + ble_device=resolved_hrm, + log_level=self.config["log_level"], + connect_timeout=float(self.config.get("ble_connect_timeout", 15)), + # Fewer attempts at startup so a missing strap surfaces the + # "continue without HRM?" prompt quickly instead of hanging + # for the full retry budget. Mid-session auto-reconnect still + # keeps retrying once a workout is underway. + connect_retries=int(self.config.get("hrm_connect_retries", 1)), + auto_reconnect=self._ble_auto_reconnect(), + reconnect_delay=float(self.config.get("ble_reconnect_delay", 5)), + status_callback=lambda ev: self._emit_status(**ev), + ) + await self.hrm.connect(callback=self._on_heart_rate_data) + self.logger.info(msg="✓ Connected to HRM") + self._emit_status(event="connected", device="hrm") + except Exception as e: + self.logger.warning(msg=f"✗ Failed to connect to HRM: {e}") + self.hrm = None + if not await self._confirm_continue_without_hrm(): + self._emit_status(event="absent", device="hrm") + raise WorkoutAbortedError( + "Workout cancelled: heart-rate monitor not reachable." + ) from e + self.logger.info(msg="Will continue without HRM") + self._emit_status(event="absent", device="hrm") + + async def _confirm_continue_without_hrm(self) -> bool: + """Ask the user whether to start the workout without the configured HRM. + + Returns True (proceed) when there is no prompt callback wired up, so + headless/CLI runs keep their previous "continue without HRM" behaviour. + The prompt is blocking, so it runs in a worker thread to keep the event + loop responsive. + """ + if self.prompt_callback is None: + return True + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + self.prompt_callback, + "Heart-rate monitor not found", + "The configured heart-rate monitor could not be connected.\n\n" + "Start the workout anyway without heart rate?", + ) + + async def _watchdog(self) -> None: + """Warn on a data stall, and auto-stop after prolonged inactivity.""" + while True: + await asyncio.sleep(delay=1) + now = time.monotonic() + + if self._last_data_monotonic is not None and not self._stalled: + gap = now - self._last_data_monotonic + if gap > self.data_stall_timeout: + self._stalled = True + self.logger.warning( + msg=f"No ergometer data for {int(gap)}s while connected." + ) + self._emit_status(event="stalled", device="ergometer") + + # Inactivity auto-stop: no forward movement for the configured time. + if ( + self.inactivity_autostop_s > 0 + and self._last_movement_monotonic is not None + and not self._auto_stopped_inactive + ): + idle = now - self._last_movement_monotonic + if idle > self.inactivity_autostop_s: + self._auto_stopped_inactive = True + minutes = int(self.inactivity_autostop_s // 60) + self.logger.warning( + msg=f"No movement for {minutes} min — auto-stopping and saving." + ) + self._emit_status( + event="inactivity_autostop", device="session", minutes=minutes + ) + self.stop_event.set() + return + + def _on_heart_rate_data(self, heart_rate: int) -> None: + """Callback for heart rate updates from HRM. + + Args: + heart_rate: Heart rate in BPM. + """ + self.latest_heart_rate = heart_rate + self.latest_heart_rate_time = time.monotonic() + self.logger.debug(msg=f"HR: {heart_rate} bpm") + + def _report_persistence_error(self, message: str) -> None: + """Record and surface the first failure to persist workout data.""" + if self._persistence_error is not None: + return + self._persistence_error = message + if self.csv_path: + try: + recovery.mark_partial( + WorkoutPaths.from_csv(self.csv_path).directory, message + ) + except OSError as exc: + self.logger.error(msg=f"Could not update recovery state: {exc}") + saved_rows = self.csv_writer.records_written if self.csv_writer else 0 + self.logger.error( + msg=f"Workout recording stopped after a storage failure: {message}" + ) + self._emit_status( + event="persistence_error", + device="session", + message=message, + saved_rows=saved_rows, + ) + + def _on_ergometer_data(self, data: dict[str, Any]) -> None: + """Callback for ergometer data updates. + + Args: + data: Dictionary of ergometer values. + """ + now_monotonic = time.monotonic() + if self._recording_timeline is None: + raise RuntimeError("Recording timeline is not initialized") + timing = self._recording_timeline.advance( + device_elapsed_s=data.get("elapsed_time__s"), + device_window_s=data.get("window_size__s"), + receipt_monotonic_s=now_monotonic, + ) + data["timestamp"] = timing.timestamp_ms + data["session_elapsed__s"] = timing.session_elapsed_s + data["sample_duration__s"] = timing.sample_duration_s + + # Feed the stall watchdog; announce recovery if data had stopped. + self._last_data_monotonic = time.monotonic() + if self._stalled: + self._stalled = False + self.logger.info(msg="Ergometer data resumed.") + self._emit_status(event="streaming", device="ergometer") + + # The same authoritative signal that owns FIT timer time also resets + # inactivity. Cumulative distance can reset after a reconnect and must + # not keep a genuinely active athlete below an old session maximum. + try: + active_paddling = float(data["active_paddling"]) > 0 + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("Ergometer packet is missing its activity signal") from exc + if active_paddling: + self._last_movement_monotonic = time.monotonic() + + # Only attach a heart-rate reading if it is recent. After the strap + # drops, the last value would otherwise be written to every row forever. + if self.latest_heart_rate is not None and ( + self.hrm_stale_timeout <= 0 + or self.latest_heart_rate_time is None + or (time.monotonic() - self.latest_heart_rate_time) + <= self.hrm_stale_timeout + ): + data["heart_rate__bpm"] = self.latest_heart_rate + + if self.csv_writer: + try: + self.csv_writer.write_record(data=data) + except CsvWriteError as e: + self._report_persistence_error(str(e)) + self.stop_event.set() + return + + # Sidecars may only advance through the last durable CSV row. If the + # write above fails, teardown keeps the preceding performed endpoint. + self._last_session_elapsed_s = timing.session_elapsed_s + self.data_point_count += 1 + + if not self._streaming_announced: + self._streaming_announced = True + self._emit_status(event="streaming", device="ergometer") + + # Live autopause / lap detection, mirroring the FIT exporter's rules. + seg = self.segmenter.update( + active=data.get("active_paddling"), + sample_duration_s=timing.sample_duration_s, + gap_before_s=timing.gap_before_s, + ) + if seg["transition"] is not None: + if seg["transition"] == "pause": + self.logger.info(msg="⏸ Auto-paused (no movement).") + elif seg["transition"] == "resume": + self.logger.info(msg=f"▶ Resumed — lap {seg['lap']}.") + self._emit_status( + event="segment", + device="session", + paused=seg["paused"], + lap=seg["lap"], + transition=seg["transition"], + ) + + # Structured program progression (if a program is running). + if self.program_runner is not None: + prog = self.program_runner.update( + elapsed_s=data.get("session_elapsed__s"), + distance_m=data.get("distance__m"), + sample_duration_s=data.get("sample_duration__s"), + ) + transitions = self._program_transition_events(prog) + if transitions: + for transition_state in transitions: + self._emit_program_status(transition_state) + self._record_step_boundary(transition_state) + self._persist_program_steps(close_open=False) + else: + self._emit_program_status(prog) + program_done = any( + item.get("transition") == "program_complete" for item in transitions + ) + + # Manual advance requested from the UI (open steps / skip)? + if ( + not program_done + and self.advance_event is not None + and self.advance_event.is_set() + ): + self.advance_event.clear() + stepped = self.program_runner.advance() + stepped_events = self._program_transition_events(stepped) + for transition_state in stepped_events: + self._emit_program_status(transition_state) + self._record_step_boundary(transition_state) + if stepped_events: + self._persist_program_steps(close_open=False) + program_done = any( + item.get("transition") == "program_complete" + for item in stepped_events + ) + + if program_done: + self.logger.info(msg="Program complete — stopping and saving.") + self.stop_event.set() + else: + # Keep the open step's end boundary current so an interrupted + # session still has a complete, conservative lap timeline. + self._persist_program_steps(close_open=False, force=False) + + # Push every data point to the live UI readout (if any). + if self.data_callback: + # Never let UI plumbing break data collection. + with contextlib.suppress(Exception): + self.data_callback(data) + + if self.data_point_count % self.config.get("log_interval", 5) == 0: + self._log_current_data(data=data) + + def _log_current_data(self, data: dict[str, Any]) -> None: + """Log current workout data to console. + + Args: + data: Latest data dictionary. + """ + metrics = [] + + if data.get("session_elapsed__s") is not None: + # int() guard: the field arrives as Decimal when the firmware sends + # it with a fraction, and Decimal rejects the "d" format code. + elapsed = int(data["session_elapsed__s"]) + mins = elapsed // 60 + secs = elapsed % 60 + metrics.append(f"Time: {mins:02d}:{secs:02d}") + + if data.get("distance__m"): + metrics.append(f"Dist: {data['distance__m']:.1f}m") + + if data.get("speed_instant__mps"): + speed_kmh = data["speed_instant__mps"] * Decimal(value="3.6") + metrics.append(f"Speed: {speed_kmh:.1f}km/h") + + if data.get("cadence_instant__spm"): + metrics.append(f"SPM: {data['cadence_instant__spm']}") + + if data.get("pull_force_instant__n"): + metrics.append(f"Pull Force: {data['pull_force_instant__n']}N") + + if data.get("heart_rate__bpm"): + metrics.append(f"HR: {data['heart_rate__bpm']}bpm") + + if metrics: + self.logger.info(msg=" | ".join(metrics)) + + def _print_workout_summary(self, workout_df: Table) -> None: + """Print workout summary statistics from the recorded data. + + Args: + workout_df: Pre-read workout data. + """ + try: + if workout_df.height == 0: + self.logger.info(msg="No workout data to summarize") + return + + self.logger.info(msg="\n" + "=" * 60) + self.logger.info(msg="WORKOUT SUMMARY") + self.logger.info(msg="=" * 60) + + summary = compute_summary( + workout_df, + max_hr=self.config.get("max_hr", 185), + zones=( + list(self.config.get("hr_zones") or []) or None + if str(self.config.get("hr_zone_mode", "auto")).lower() == "manual" + else None + ), + program_steps=load_program_steps(self.csv_path) if self.csv_path else None, + pull_length_m=self.config["pull_length_m"], + ) + total_distance = summary.distance_m + active_time = summary.active_time_s + elapsed_time = summary.elapsed_time_s + + mins = int(active_time // 60) + secs = int(active_time % 60) + + self.logger.info(msg=f"Active time: {mins:02d}:{secs:02d}") + self.logger.info(msg=f"Elapsed: {elapsed_time:.1f} s") + self.logger.info(msg=f"Distance: {total_distance:.1f} m") + self.logger.info(msg=f"Data Points: {workout_df.height}") + self.logger.info(msg="=" * 60 + "\n") + + self._emit_status( + event="summary", + device="session", + distance_m=total_distance, + active_time_s=active_time, + elapsed_time_s=elapsed_time, + pause_time_s=summary.pause_time_s, + record_count=int(workout_df.height), + csv_path=self.csv_path, + incomplete=self._persistence_error is not None, + persistence_error=self._persistence_error, + ) + + except Exception as e: + self._summary_error = str(e) + self.logger.error(msg=f"Failed to generate workout summary: {e}") + traceback.print_exc() diff --git a/app/write_csv.py b/app/write_csv.py new file mode 100644 index 0000000..6e8b219 --- /dev/null +++ b/app/write_csv.py @@ -0,0 +1,154 @@ +"""Workout CSV writing. + +Streams workout records to a semicolon-separated CSV (spreadsheet-friendly) as +they arrive: every row is flushed immediately and the file is periodically +fsync'ed, so an interrupted session keeps its data, and the file is made +read-only on close to protect the recording. The write path is deliberately +simple; the defensive per-cell/per-row recovery lives in the read path +(``app.read_csv``). +""" + +import contextlib +import csv +import os +import stat +import time +from typing import Any + +from .field_mapping import CSV_COLUMNS +from .logger import Logger + +# Force data to physical disk at most this often (seconds). flush() pushes to +# the OS buffer every row; fsync() guarantees durability across a power loss +# without the cost of syncing on every single record. +FSYNC_INTERVAL_S = 5.0 + + +class CsvWriteError(OSError): + """Raised when a workout row cannot be persisted safely.""" + + +class CsvWriter: + """Writer for workout data in CSV format with semicolon separator.""" + + def __init__(self, output_path: str) -> None: + """Initialize CSV writer. + + Args: + output_path: Path to output .csv file. + """ + self.output_path = output_path + self.file: Any | None = None + self.writer: csv.DictWriter[str] | None = None + self._last_fsync = 0.0 + self.records_written = 0 + self.failed = False + + self.logger = Logger.get_logger(name=__name__) + + self._open() + + def _open(self) -> None: + """Open CSV file for writing and write header.""" + try: + # The file must stay open across many write_record() calls and is + # closed explicitly in close() (this object is itself a context + # manager, see __enter__/__exit__), so a `with` here would close + # it prematurely despite SIM115. + self.file = open( # noqa: SIM115 + file=self.output_path, mode="x", newline="", encoding="utf-8" + ) + self.writer = csv.DictWriter( + f=self.file, + fieldnames=CSV_COLUMNS, + delimiter=";", + extrasaction="ignore" + ) + self.writer.writeheader() + self.file.flush() + os.fsync(self.file.fileno()) + self.logger.info(msg=f"Opened CSV file: {self.output_path}") + except FileExistsError: + raise + except Exception as e: + if self.file is not None: + with contextlib.suppress(Exception): + self.file.close() + self.file = None + with contextlib.suppress(OSError): + os.unlink(self.output_path) + self.logger.error(msg=f"Failed to open CSV file: {e}") + raise CsvWriteError(f"Could not create workout CSV: {e}") from e + + def write_record(self, data: dict[str, Any]) -> None: + """Write a single data record. + + Args: + data: Dictionary containing workout data point. + """ + if self.failed: + raise CsvWriteError("CSV writer is unavailable after an earlier write failure") + if not self.writer or not self.file: + raise CsvWriteError("CSV writer is not initialized") + + try: + record = {field: data.get(field) for field in CSV_COLUMNS} + self.writer.writerow(rowdict=record) + # Flush each record so an unexpected shutdown does not lose data... + self.file.flush() + # ...and periodically fsync so a hard power loss can't drop rows + # still sitting in the OS write cache. An fsync failure matters: the + # application must not report a row as durable after the filesystem + # has rejected the durability boundary. + now = time.monotonic() + if now - self._last_fsync >= FSYNC_INTERVAL_S: + os.fsync(self.file.fileno()) + self._last_fsync = now + self.records_written += 1 + except Exception as e: + self.failed = True + self.logger.error(msg=f"Error writing record: {e}") + raise CsvWriteError( + f"Could not save workout row {self.records_written + 1}: {e}" + ) from e + + def close(self) -> str: + """Close CSV file. + + Returns: + Path to the closed file. + """ + close_error: Exception | None = None + if self.file: + try: + self.file.flush() + os.fsync(self.file.fileno()) + except (OSError, ValueError) as e: + close_error = e + self.failed = True + try: + self.file.close() + except OSError as e: + close_error = close_error or e + self.failed = True + self.file = None + self.writer = None + + try: + os.chmod(path=self.output_path, mode=stat.S_IREAD) + self.logger.info(msg=f"Closed CSV file: {self.output_path} [READ-ONLY]") + except Exception: + self.logger.info(msg=f"Closed CSV file: {self.output_path}") + + if close_error is not None: + raise CsvWriteError(f"Could not finalize workout CSV: {close_error}") from close_error + + return self.output_path + + def __enter__(self) -> CsvWriter: + """Context manager entry.""" + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Context manager exit.""" + self.close() diff --git a/assets/config.ico b/assets/config.ico new file mode 100644 index 0000000..c4e453d Binary files /dev/null and b/assets/config.ico differ diff --git a/assets/icon_kayakfit.icns b/assets/icon_kayakfit.icns new file mode 100644 index 0000000..636e43b Binary files /dev/null and b/assets/icon_kayakfit.icns differ diff --git a/assets/icon_kayakfit.ico b/assets/icon_kayakfit.ico new file mode 100644 index 0000000..aa991c2 Binary files /dev/null and b/assets/icon_kayakfit.ico differ diff --git a/assets/kayakfirst.icns b/assets/kayakfirst.icns new file mode 100644 index 0000000..0767a14 Binary files /dev/null and b/assets/kayakfirst.icns differ diff --git a/assets/kayakfirst.ico b/assets/kayakfirst.ico new file mode 100644 index 0000000..52b70f4 Binary files /dev/null and b/assets/kayakfirst.ico differ diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..c6e7e91 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,180 @@ +# Instructions for AI assistants + +This is the repository-level orientation for every AI assistant working on +KayakFit. Read this file before changing code. Then consult the domain document +that matches the task: + +- [ARCHITECTURE.md](ARCHITECTURE.md) — runtime design and data flow. +- [DATA_TRANSFORMATIONS.md](DATA_TRANSFORMATIONS.md) — user-facing sensor, + summary, FIT, and Strava calculation rules. +- [FEATURES.md](FEATURES.md) — implemented user-facing behavior and verification status. +- [ROADMAP.md](ROADMAP.md) — open findings and prioritized work. +- [DECISIONS.md](DECISIONS.md) — architectural rationale and invariants. +- [STYLE_GUIDE.md](STYLE_GUIDE.md) — coding, typing, testing, and logging conventions. +- [DEVELOPMENT.md](DEVELOPMENT.md) — local setup and day-to-day change workflow. +- [TESTING.md](TESTING.md) — automated gates, invariant tests, and manual checks. +- [DEPLOYMENT.md](DEPLOYMENT.md) — packaged builds, releases, and rollback. +- [POWER_MODEL.md](POWER_MODEL.md) — estimated-power calibration. +- `README.md` — end-user setup and operation. +- `CONTRIBUTING.md` — branches, pull requests, CI, and releases. + +## Repository purpose + +KayakFit is a Python 3.14 CustomTkinter desktop application for macOS and +Windows. It connects to a KayakFirst Bull ergometer and an optional standard BLE +heart-rate monitor, displays live workout data, streams the raw recording to +CSV, creates an indoor-kayaking FIT activity, shows a post-workout summary, and +can upload the FIT file to Strava. + +The main dependencies are `bleak`, `customtkinter`, `fit_tool`, `requests`, and +PyInstaller. Dependencies are declared in `pyproject.toml` and pinned by +`uv.lock`. `requirements.txt` is a generated fallback and must not be edited by +hand. + +## Branch and integration rules + +- Work on a feature or fix branch created from `main`; keep `main` releasable. +- Do not merge, push, or synchronize a branch unless the user explicitly asks. +- Integration into `main` is performed through Claude. Leave a clear branch + history and update these documents when behavior or architecture changes so + Claude can review the branch without reconstructing its intent. +- Preserve unrelated user changes in a dirty worktree. +- Do not rewrite history, force-push, reset destructively, or discard user work. +- Before handing off a behavioral change, update `FEATURES.md`, `ROADMAP.md`, + `DECISIONS.md`, or `ARCHITECTURE.md` as appropriate. + +## Build, run, and validate + +```bash +uv sync +uv run python kayakfit_gui.py +uv run ruff check . +uv run mypy . --strict +``` + +```bash +uv run pytest +``` + +## Environment cautions + +- A local `.venv` can contain platform-specific Python symlinks. Do not repair + or commit it when it fails in a different operating system or sandbox. +- An offline environment may lack `fit_tool`, pytest, Ruff, or mypy. Distinguish + missing tooling from a product regression and report what could not be run. +- Use a writable temporary uv cache in restricted environments when necessary; + do not alter project files merely to work around a sandbox cache restriction. +- PyInstaller output directories and `.venv` are generated artifacts and are + excluded from linting, typing, and version control. + +## Critical domain invariants + +These rules must survive every refactor: + +1. The CSV is the durable observation log. Valid stationary and repeated rows + are retained; segmentation happens later. +2. `active_paddling` is required and authoritative for whether an ergometer + sample belongs to the FIT activity. Heart-rate notifications never create + records or extend the activity. +3. `finalize_workout()` owns the finalized active record set, timer runs, laps, + and totals consumed by both FIT generation and the summary. +4. Planned workouts group active samples by performed step; they do not add + inactive samples to averages. Fully inactive planned rests may remain visible + with blank active statistics. +5. Every exact positive-duration activity run owns FIT timer events and active + metrics. Summary chart lines in both workout modes, plus free-workout live + pause/lap state and laps, group runs across inactive or uncovered gaps shorter + than the fixed five-second meaningful-pause delay. Planned laps remain + step-based; no autopause threshold is configurable. +6. Historical processing uses the recording's `metadata.json`, not whatever + settings happen to be current later. +7. FIT output is written to a sibling temporary file and atomically replaces the + destination only after a successful build. +8. The synthetic Hazewinkel GPS track is a documented Strava-compatibility + field calculated only from finalized active distance. Each active run starts + with one metric-free anchor record at its exact timer START so its GPS span + covers the complete first sample window. Do not fabricate calories, altitude, + arbitrary coordinates, or other unsupported measurements. +9. Worker threads never manipulate Tk widgets directly; they send events that + are marshalled onto the GUI thread. +10. A completed Strava upload and a merely accepted/pending upload are different + states. + +Open violations or weaknesses around these invariants are tracked in +[ROADMAP.md](ROADMAP.md); do not treat the presence of a shared function as +proof that every encoded result is valid. + +## Error-handling policy + +For user-controlled or externally supplied data—program JSON, workout CSV, +sidecars, BLE packets, and API responses—use this hierarchy: + +1. Validate at the point of use, not only at initial parsing. +2. If records are independent, skip only the malformed local unit and log its + identity and reason. +3. Do not infer missing v1 workout fields or change planned workouts to free. +4. Fail the operation when continuing would create a misleading workout or + falsely report persistence/export success. +5. Surface failures that affect saved data, workout interpretation, or external + state to the user; a log entry alone is not sufficient. + +Examples already implemented include per-file program rejection, per-row CSV +recovery with a corruption threshold, strict rejection of unusable planned-step +sidecars, and terminal propagation of CSV write failures without false +saved-row or automatic-export success. + +## Repository map + +```text +kayakfit_gui.py process entry point and Tk main loop +workout_worker.py async recording worker entry +export_worker.py FIT/export/upload worker entry + +app/ + ble_device.py common BLE retry/reconnect lifecycle + kayakfirst_ergometer_bluetooth.py + ergometer protocol, handshake, polling, parsing + heart_rate_monitor_bluetooth.py + standard HR and battery notifications + workout_session.py recording orchestration and sidecars + write_csv.py streaming semicolon CSV writer + read_csv.py defensive CSV reader into Table + table.py lightweight stdlib-only tabular container + segmentation.py activity-run and free-lap detection + finalized_workout.py shared FIT/summary activity domain + stats.py shared workout aggregates and HR zones + summary.py pure post-workout summary model + export_fit.py atomic FIT construction + program.py JSON program model, loading, and validation + program_runner.py live planned-step progression + workout_metadata.py reproducible processing settings sidecar + recovery.py interrupted-workout marker + power.py estimated-power formula + strava_api.py direct HTTP API boundary + strava_auth.py OAuth loopback flow + strava_uploader.py token refresh, upload, and polling + +gui/ + controllers.py typed recording/program/export state owners + main_gui.py root window construction and composition + recording_lifecycle.py live metrics and workout lifecycle + program_panel.py planned-workout UI and targets + device_status.py device, HR-zone, pause, and upload status + export_upload.py export/upload action coordination + worker_manager.py worker threads and GUI event marshalling + summary_window.py summary charts, zones, and splits + history_window.py workout history and external-file actions + config_window.py settings UI + config_manager.py config defaults, loading, and persistence + +tests/ isolated plain-assert pytest modules +presets/ bundled program JSON copied to the user directory +``` + +`KayakFitGUI` is composed from several mixins. If a method is absent from +`gui/main_gui.py`, inspect the mixin base list and the modules above before +concluding that the behavior is missing or dead. + +The project deliberately uses `app.table.Table` instead of Polars to reduce the +packaged application size. Do not reintroduce a dataframe dependency without a +measured need and an explicit architecture decision. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..03d4141 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,408 @@ +# KayakFit architecture + +This document describes the current system design from BLE capture through CSV, +FIT, summary, and Strava. The user-facing calculation path is documented in +[DATA_TRANSFORMATIONS.md](DATA_TRANSFORMATIONS.md). Design rationale is in +[DECISIONS.md](DECISIONS.md), user-visible behavior is in +[FEATURES.md](FEATURES.md), and known defects are in [ROADMAP.md](ROADMAP.md). + +## System context + +KayakFit is a Python 3.14 desktop application for macOS and Windows. The process +contains one CustomTkinter UI thread and, at most, one active background worker +managed by `gui.worker_manager.WorkerManager`. + +```mermaid +flowchart LR + ERG[KayakFirst Bull] -->|BLE serial notifications| SESSION[WorkoutSession] + HRM[BLE HR monitor] -->|HR notifications| SESSION + SESSION -->|one row per ergometer response| CSV[(CSV)] + SESSION --> META[(metadata.json)] + SESSION --> STEPS[(steps.json)] + + CSV --> READER[CsvReader] + META --> READER + STEPS --> FINAL[finalize_workout] + READER --> FINAL + FINAL --> FIT[(FIT)] + FINAL --> SUMMARY[Workout summary] + FIT -->|optional REST upload| STRAVA[Strava] +``` + +Top-level entry points: + +- `kayakfit_gui.py` configures logging/crash diagnostics and starts Tk. +- `workout_worker.py` creates the async recording session in a worker thread. +- `export_worker.py` converts CSV to FIT and optionally uploads it. + +## UI composition and thread model + +`gui.main_gui.KayakFitGUI` constructs the root window and combines four behavior +mixins: + +- `RecordingLifecycleMixin` — start/stop, live metrics, completion, recovery. +- `ProgramPanelMixin` — selected program, step progress, targets, cues. +- `DeviceStatusMixin` — connection, HR zone, pause, and upload state. +- `ExportUploadMixin` — FIT/export/upload actions and config refresh. + +`gui._mixin_base.GuiSharedState` exists only under `TYPE_CHECKING` to describe +the remaining widget/method surface shared by these mixins. Mutable recording, +program, and export state has explicit owners in `gui.controllers`; it no +longer lives as unrelated attributes on the root window. + +`gui.history_window` lists canonical CSV recordings and selected external +CSV/FIT files. Its small `Plan`/`Free` badge reads the required versioned +metadata workout mode. Invalid CSV metadata receives an `Invalid` badge. +Imported FIT files use a neutral `FIT` badge because the +original workout mode is not encoded reliably enough to infer it. +Its search is an in-memory filter over the bounded visible list; it does not +introduce a workout index or database. + +Workout summaries load and compute on a short-lived background thread. Only +the resulting immutable-style summary model crosses back to Tk for widget +construction. Charts retain full-resolution series for statistics and hover +inspection, while their Canvas polylines use cached pixel-width min/max +downsampling and debounced resize redraws. + +BLE, FIT, and HTTP work never runs directly in a Tk callback. `WorkerManager` +owns a daemon thread, stop/advance `threading.Event` objects, and event +marshalling. Each worker returns exactly one `WorkerResult`: thread completion +is not treated as successful work. Outcomes distinguish success, cancellation, +partial durable output, failure, and pending external processing. +Callbacks are marshalled through `widget.after(0, ...)`. `app.events` defines +the typed status, metrics, and terminal-event contracts crossing the worker/UI +boundary. + +## Configuration and user data + +`gui.config_manager.ConfigManager` is the shared config persistence boundary. + +```text +~/KayakFit/ + config.yml + .active_workout.json + logs/ + programs/ + workouts// +``` + +- YAML is loaded with `safe_load` and merged with canonical defaults. +- Config writes use an owner-only temporary file and atomic replacement. +- Strava authorization is an atomic bundle (client ID, client secret, access + token, refresh token, and expiry) stored through `app.secret_store` in the + operating-system credential vault where available. A connection is valid + only when the full bundle is present. The owner-only YAML file is the fallback. +- Runtime workers validate the configured ergometer and weight types before a + session begins. +- `ConfigManager.validate_config()` owns the complete range/type validation used + by settings and both workers. Invalid explicit values fail rather than being + silently replaced. + +Per-workout `metadata.json` records processing inputs so later exports do not +silently change when current configuration changes. + +## BLE layer + +### Common lifecycle + +`app.ble_device.BleDevice` provides: + +- bounded connect attempts with exponential backoff; +- reuse of an already-discovered `BLEDevice`, avoiding implicit address scans; +- notification subscription; +- unexpected-disconnect callbacks; +- repeated mid-session reconnect attempts; +- stop-aware teardown; +- structured device status callbacks. + +Before opening a fresh client it closes any previous half-open connection. A +deliberate disconnect sets `_should_run = False` before closing so the +disconnected callback does not start another reconnect loop. + +### Ergometer protocol + +`app.kayakfirst_ergometer_bluetooth.KayakFirstErgometer` uses a serial-style BLE +characteristic. Commands are ASCII, semicolon-separated, CRLF-terminated, and +written in 20-byte chunks. + +Initial connection performs the firmware sequence: + +1. reset; +2. second reset; +3. time/timezone/person-weight/boat-weight handshake; +4. five-slot display configuration; +5. workout start. + +The application then sends a poll command approximately once per second. Each +data response is reassembled from an arbitrary BLE notification stream: one +notification can contain several lines or a partial line. Complete `\r\n` +records are parsed in order, partial bytes remain buffered, and an unterminated +oversized buffer is discarded. Reconnect clears the partial buffer but does not +repeat the reset/handshake, to avoid resetting an in-progress ergometer workout. + +Parsed fields include cumulative distance, elapsed time, instantaneous and +averaged speed/cadence/pace/force, sample window, and active-paddling state. A +central positional map in `app.field_mapping` owns the protocol-to-column names. + +### Heart-rate monitor + +`app.heart_rate_monitor_bluetooth.HeartRateMonitor` uses the standard BLE Heart Rate +Measurement characteristic and optional Battery Level characteristic. It +decodes both 8-bit and 16-bit HR values from the flags byte. + +At workout startup, `WorkoutSession` resolves the configured HRM before opening +the ergometer link. The resolved backend device is then reused for the initial +HRM connection and reconnects, so Bleak does not need to scan for the HRM while +another BLE connection is active. Discovery is attempted twice before the user +is offered an ergometer-only workout. + +HR notifications are free-running and asynchronous. They update +`WorkoutSession.latest_heart_rate`; they do not produce CSV rows. A failed +optional HRM connection can be accepted by the user, allowing an ergometer-only +workout. + +## Recording pipeline + +`app.workout_session.WorkoutSession` owns the live session: + +1. Establish an epoch anchor, monotonic anchor, and `RecordingTimeline`. +2. Reserve a timestamped directory under the year and open `workout.csv` in it. +3. Save processing metadata and mark the workout recoverable. +4. Connect/start the ergometer and optional HRM. +5. For each ergometer record: + - persist the canonical endpoint and non-overlapping represented duration; + - attach the latest HR only while it is fresh; + - append the full protocol row to CSV; + - update the live free-workout segmenter; + - update the planned-program runner when present; + - emit live metrics/status to the GUI. +6. On stop, send the device stop command, disconnect, close/fsync the CSV, + finalize any planned step, and emit the completion summary. + +CSV rows are flushed immediately and periodically fsynced. The active recovery +marker points at the current CSV so the next launch can offer FIT conversion +after a crash. A row-write failure places the writer in a terminal failed state, +stops the session, retains the last durable rows for recovery, and prevents a +false successful save/export result. + +HR freshness is measured entirely with monotonic receipt and processing times; +epoch time is used only for recorded timestamps. + +## Raw CSV model + +`app.write_csv.CsvWriter` writes every field from `app.field_mapping.CSV_COLUMNS` +using a semicolon delimiter. It does not decide which records count as activity. + +`app.read_csv.CsvReader`: + +- requires the exact v1 schema; +- parses values defensively; +- skips individually corrupt/truncated rows; +- rejects a predominantly corrupt file; +- preserves stationary and repeated valid rows; +- projects instantaneous cadence, pull force, and speed for processing; +- returns the small stdlib-only `app.table.Table` abstraction. + +The CSV remains the durable observation source even after FIT generation. + +## Activity selection and finalization + +`app.segmentation` and `app.finalized_workout` have separate responsibilities: + +- `LiveSegmenter` presents a pause after five continuous inactive seconds. +- `resolve_segments()` determines exact active runs for final processing. +- `finalize_workout()` creates the complete domain consumed by FIT and summary. + +`active_paddling` is required and authoritative. Every positive sample is +active; every zero sample is inactive. Speed, cadence, HR, and cumulative +distance never infer timer state. + +A finalized workout contains: + +- a canonical represented interval and epoch endpoint for every row; +- exact contiguous active segments; +- active record indices; +- FIT/summary lap groups; +- chart break indices; +- total active timer time; +- complete elapsed-zero to final-record session time; +- cumulative distance at the last active record. +- an explicit represented duration for every emitted record. + +Activity runs and laps are frozen typed records. Finalization is a small +orchestrator over pure phases for sample windows, active distance, planned/free +lap construction, chart breaks, and conservation/rounding. + +Each row represents `[session_elapsed__s - sample_duration__s, +session_elapsed__s]`. `timestamp` is the same endpoint on the session epoch. +Raw device timing is diagnostic only. Session timer time is the sum of active +represented windows; inactive and uncovered time remains elapsed time. +Ergometer requests use fixed one-second monotonic deadlines, including BLE +write time inside the period and skipping catch-up bursts after a late write. +The complete field ownership, capture formulas, and normal/delayed/reset +examples are the canonical timeline contract in +[DATA_TRANSFORMATIONS.md](DATA_TRANSFORMATIONS.md#1-canonical-elapsed-timeline). + +The planned lap totals must conserve the finalized session's active timer time +and distance. Invalid or incomplete timelines fail processing instead of +silently changing workout type or omitting samples. + +### Free workouts + +Exact runs own FIT timer events and active metrics. Every inactive record closes +the current exact run; every following active record starts another. Free laps, +the live pause/lap state, and chart lines in either workout mode use a fixed +five-second meaningful-pause delay: shorter inactive or uncovered gaps remain +inside the same presented lap or unbroken chart line. The threshold uses +canonical elapsed time, not packet count, and is not configurable. Inactive +records remain excluded from active time and averages even when the surrounding +exact runs share a lap. + +### Planned workouts + +`app.program` loads JSON into flattened `Program`/`Step` models. Repeat depth, +expansion work, and total steps are bounded. `ProgramRunner` consumes ergometer +elapsed time and cumulative distance on each sensor sample. Open steps require a +manual advance. + +`WorkoutSession` records strict canonical session-elapsed step boundaries in +`steps.json`; epoch boundary fields are not part of v1. +Transition and final boundaries are persisted immediately; an open step is +checkpointed at a bounded interval rather than fsyncing its sidecar for every +sensor sample. +Finalization uses those boundaries to create a lap for every performed step; +active selection remains independent. A fully inactive rest produces an empty +step lap. Planned-workout metadata requires a complete, valid sidecar; a missing +or unusable timeline fails closed instead of silently changing the workout to +free-workout grouping. + +Time steps are driven by canonical session elapsed time rather than an +independent periodic wall clock. A delayed sample can cross multiple boundaries: +`ProgramRunner` preserves its overshoot and reports every exact transition. +`ProgramRunner` also validates that the canonical timing it receives is +monotonic and non-overlapping in integer milliseconds, so an exactly-filled +window never fails on float-subtraction rounding inside the live BLE callback. +Finalization intersects each represented sample window with performed step +intervals, so timer time, metrics, and distance are apportioned conservatively. +Planned summary chart lines break only at the same five-second meaningful-pause +boundaries as free workouts; performed step boundaries render as chart bands, +not line breaks (D019). + +## Statistics and power + +`app.workout_metrics.compute_finalized_metrics()` is the final FIT/summary +aggregate boundary, using `app.stats.aggregate_metrics()` for each session/lap +slice. HR, instantaneous cadence, and estimated power use positive valid +readings and active-overlap weights. Power is derived per record from +instantaneous cadence and pull force. `app.active_distance.ActiveDistanceAccumulator` owns +active distance/time attribution for both finalization and the live dashboard. +Speed uses the independent recorded instantaneous channel (`app.speed_series`, +D017): averages are weighted by active represented intervals and maxima are +raw active peaks. FIT and summary charts carry those unsmoothed points. +HR-zone time is accumulated over active segments only, with missing-HR time +presented separately. + +Power is estimated by `app.power`: + +```text +power_W = pull_force_N × pull_length_m × cadence_spm / 60 +``` + +The default effective pull length is 0.600 m. See +[POWER_MODEL.md](POWER_MODEL.md). Pull length is an explicit processing input +recorded in workout metadata, so historical output is independent of later +configuration changes. + +## FIT construction + +`app.export_fit.FitExporter` loads recorded metadata, reads `workout.csv`, loads +`steps.json`, and finalizes the workout. A stationary recording with no +active segment is rejected rather than fabricated into an activity. + +FIT message order is: + +1. file ID; +2. device information; +3. timer START/STOP events around each exact active run, one metric-free GPS + anchor at each START, and active sensor records in chronological order; +4. lap messages for planned steps or natural laps; +5. one session message. + +Records contain timestamp, active-only cumulative distance, instantaneous speed, +HR, instantaneous cadence, estimated power from instantaneous cadence/force when +available, and a synthetic position on the 2 km Hazewinkel +course derived from that same cumulative distance. Strava uses the positions to +calculate kayaking overview moving time across pauses. Because sensor samples +represent windows ending at their timestamps, a metric-free record anchors the +previous distance and position at every active-run START; otherwise Strava +drops that run's first window. The session/laps contain timer and elapsed time, +distance, averages, and maxima. No calories or altitude are generated. + +FIT is built to `.fit.tmp` and atomically promoted to `.fit`. A +failed rebuild leaves a previous FIT untouched. + +## Summary construction + +`app.summary.compute_summary()` consumes the same finalized workout and shared +statistics as FIT construction. It returns active, elapsed and pause totals; +lap active/elapsed time and following gaps; headline values, HR-zone seconds, +exact emitted-record series, chart breaks, and planned step bands. Session +elapsed time reconciles as the sum of lap elapsed time plus following gaps. + +`gui.summary_window` renders that model with CustomTkinter and a plain Tk Canvas. +Each chart point uses the CSV sample endpoint, exactly matching the timestamp of +its FIT sensor record. The FIT timer can begin one represented sample window +before the first point because the record describes the interval ending there. +So the drawn line spans match the splits table on both axes, each run's line +begins at a run-start anchor — the timer START on the time axis and the +cumulative distance preceding its first window on the distance axis — reusing the +`series_anchor_t`/`series_anchor_distance` boundaries and mirroring the FIT +run-start anchor (D020). +Time-based planned workouts render their phases once in a compact strip above +the metric panels. Step boundaries are shared alignment guides across those +panels; the old per-panel phase fills are intentionally not repeated. Distance +mode omits the strip because elapsed-time boundaries have no exact distance-axis +mapping. + +Headline and split aggregates are verified against decoded FIT output. Charts +show raw recorded points; legend averages, maxima, and average lines consume +the authoritative `WorkoutSummary` aggregates. + +## Strava and external state + +`app.strava_api` is the only HTTP boundary. `StravaAuth` implements an OAuth +authorization-code flow with a loopback listener on `127.0.0.1`, per-flow state, +and manual code fallback. `StravaUploader` refreshes tokens, sends multipart FIT +uploads with a stable external ID, polls processing status, and reads back a +confirmed activity when possible. + +An accepted upload whose poll exceeds the deadline returns `pending=True`. +Pending is not the same as confirmed success. The upload ID is not currently +persisted, so polling cannot resume after a restart; see ROADMAP P1. + +## Recovery and shutdown + +On startup, a valid active-workout marker identifies a workout directory whose +`workout.csv` contains at least one row, producing a recovery prompt. The user +can convert it to FIT and optionally upload it. Missing/empty targets self-clear. + +During application shutdown, the GUI signals the active worker and waits a +bounded period for recording teardown or an in-flight external operation. Logs +and `faulthandler` preserve Python and native crash information for windowed +builds. + +## Verification boundaries + +Automated coverage includes BLE packet framing, program parsing/progression, +CSV parsing, recovery, segmentation, HR zones, shared statistics, FIT events, +planned/free laps, summary behavior, decoded FIT parity, and mocked Strava REST +flows. + +The suite does not prove: + +- behavior against physical BLE hardware and firmware variants; +- live Strava service compatibility; +- CustomTkinter interactions and window teardown races; +- disk-full or permission-loss behavior; +- packaged application behavior on every OS version; +- long-workout chart responsiveness. diff --git a/docs/DATA_TRANSFORMATIONS.md b/docs/DATA_TRANSFORMATIONS.md new file mode 100644 index 0000000..ac01d33 --- /dev/null +++ b/docs/DATA_TRANSFORMATIONS.md @@ -0,0 +1,333 @@ +# From recorded points to Summary, FIT, and Strava + +This document is the reproducible processing contract for KayakFit workouts. +An end user can apply these rules to `workout.csv`, plus `steps.json` for a +planned workout, and obtain the same timing, distance, lap, and speed values as +the app summary and generated FIT file. + +## What is guaranteed + +KayakFit calculates the summary and FIT session/lap messages from one finalized +workout and one aggregate result. Within KayakFit: + +- session active distance equals the sum of lap active distances; +- session active time equals the sum of lap active times; +- paused intervals never lower speed, heart-rate, cadence, or power averages; +- average speed is no greater than maximum speed when speed is available; +- chart, summary, and FIT use recorded instantaneous sensor points, including + estimated power derived from instantaneous cadence and pull force; +- main-window sensor cards consistently use instantaneous device fields; +- decoded FIT session/lap metrics match the summary, subject to FIT precision. + +Strava is outside this guarantee. It can recalculate moving time, distance, and +speed after import. + +## The complete path + +```text +KayakFirst + HR monitor + | + v +workout.csv + metadata.json (+ steps.json for a plan) + | + v +FinalizedWorkout + canonical sample windows, activity runs, distance, laps + | + +-----------------------+ + | | + v v +FinalizedMetrics active record series + session + laps speed, HR, cadence, power + | | + +-----------+-----------+ + | + +------+------+ + | | + v v + app summary generated FIT -> Strava +``` + +The CSV is the durable observation log. It retains inactive and repeated rows. +Finalization, not CSV reading, decides which intervals are active. + +## 1. Canonical elapsed timeline + +KayakFit polls the ergometer on fixed one-second monotonic deadlines. Time spent +writing the BLE command is part of that period, and a late command never causes +a burst of catch-up polls. A response can still be delayed, contain multiple +complete protocol lines, or arrive after a reconnect, so polling time alone is +not a safe processing clock. + +The timing-related CSV fields have distinct owners: + +| CSV field | Owner | Meaning and processing role | +| --- | --- | --- | +| `timestamp` | KayakFit | Epoch milliseconds at the canonical row endpoint. | +| `session_elapsed__s` | KayakFit | Monotonic canonical endpoint since session start. | +| `sample_duration__s` | KayakFit | Non-overlapping duration represented by the row; this is the averaging and active-time weight. | +| `kayakfirst_timestamp` | Ergometer | Raw diagnostic timestamp. It can repeat while idle and is never the processing clock. | +| `active_paddling` | Ergometer | Required activity flag applied to the complete represented interval. | +| `elapsed_time__s` | Ergometer | Raw elapsed counter used at capture to advance the canonical endpoint. | +| `window_size__s` | Ergometer | Raw field KayakFit interprets as requested coverage duration. It is not used as a response-local row number and is capped before becoming `sample_duration__s`. | + +`RecordingTimeline` creates the three KayakFit fields once, when each complete +ergometer line is received. Normally the raw elapsed-counter increase advances +the canonical endpoint. If that counter stalls or resets, the endpoint rebases +to monotonic receipt time so it cannot move backwards. Then: + +```text +available duration = session_elapsed[i] - session_elapsed[i - 1] +sample_duration[i] = min(window_size[i], available duration) +uncovered gap = available duration - sample_duration[i] +``` + +For the first row, the previous endpoint is elapsed zero. Durations are anchored +at their endpoint, so an uncovered gap occurs before the row's represented +interval. + +For rows numbered `i = 0..n-1`: + +```text +interval i = [session_elapsed[i] - sample_duration[i], session_elapsed[i]] +elapsed workout time = session_elapsed[n-1] +``` + +Endpoints must be finite, non-negative, and non-decreasing. Durations must be +finite, non-negative, no greater than their endpoint, and must not overlap the +preceding row endpoint. This permits an initial or repeated zero-duration +observation without inventing time. All rows must share one epoch origin: + +```text +timeline epoch start = timestamp[i] - session_elapsed[i] * 1000 +``` + +`elapsed_time__s`, `window_size__s`, `kayakfirst_timestamp`, and other raw +device fields remain audit data only. There is no processing fallback. + +Typical capture examples are: + +| Case | Previous endpoint | Raw elapsed | Raw window | Canonical endpoint | Sample duration | Represented interval | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| Normal poll | 0 s | 1 s | 1 s | 1 s | 1 s | `[0, 1]` | +| One delayed row covers two seconds | 1 s | 3 s | 2 s | 3 s | 2 s | `[1, 3]` | +| Window smaller than elapsed advance | 1 s | 3 s | 1 s | 3 s | 1 s | `[2, 3]`, leaving `[1, 2]` uncovered | +| Lagging two-second window on next row | 3 s | 4 s | 2 s | 4 s | 1 s | `[3, 4]`; it is clipped rather than overlapping `[2, 3]` | + +After a reconnect, suppose the previous canonical endpoint is 5 seconds, the +raw counter resets to zero, and the packet is received at monotonic session time +30 seconds. The new canonical endpoint is 30 seconds. A one-second window +represents `[29, 30]`; `[5, 29]` stays explicitly uncovered. KayakFit does not +invent sensor readings or activity for that transport gap. + +Final processing reads only `timestamp`, `session_elapsed__s`, and +`sample_duration__s`. It never recalculates history from the three raw device +time fields. In particular, a raw `window_size__s` of 2 contributes two seconds +only when the canonical endpoints actually have two seconds available. +When a retained row represents more than one second, its one instantaneous +sensor reading represents that complete duration for time-weighted metrics. +KayakFit does not interpolate missing points or switch to a different metric +channel for multi-second rows. + +The complete recording bounds, including an inactive prefix or tail, define the +FIT session and chart time axis. + +## 2. Active and elapsed time + +`active_paddling` is required and authoritative: + +- `1`: the complete row interval is active; +- `0`: the complete row interval is inactive; +- missing or invalid: reject the workout instead of inferring from another + sensor. + +```text +active time = sum(sample_duration of intervals with active_paddling = 1) +elapsed time = final session_elapsed__s +pause time = elapsed time - active time +``` + +Heart-rate notifications never create records or extend time. + +Every exact `0 -> 1` transition begins a new active run. Every exact `1 -> 0` +transition closes it. An uncovered interval also closes a run, even when the +next row is active. Exact runs drive active time, metrics, distance attribution, +and FIT timer events. Zero-duration observations belong to no active run. + +A fixed five-second meaningful-pause delay governs presentation grouping. The +live pause indicator, lap counter, and free laps split only after at least five +continuous canonical seconds without activity. Chart lines behave the same way in +both workout modes: exact runs less than five seconds apart—whether separated by +inactive samples or an uncovered gap—stay one continuous line, so a brief cleared +activity flag or a high-cadence uncovered second does not fragment a continuous +effort into disconnected pieces. Shorter gaps remain inside one lap, but their +inactive samples still contribute neither active time nor metric averages. The +threshold uses elapsed duration, not record count, and is not configurable. +Planned laps continue to follow the performed program steps, which render as +chart bands rather than line breaks. + +## 3. Active distance + +`distance__m` is the recorded cumulative odometer. Processing is deliberately +simple and never rewrites it using speed: + +1. Keep the latest valid odometer value as the baseline. +2. For an active interval, credit `max(current - baseline, 0)` metres. +3. For an inactive interval, advance the baseline but credit zero metres. +4. Credit the complete raw delta on the first active record after a pause. Do + not cap it using instantaneous speed. +5. If an active odometer reading is missing, defer the span to the next valid + reading and distribute that delta over the pending active intervals by + duration. +6. Round the session to centimetres and allocate lap centimetres so lap + distances sum exactly to the session distance. + +This preserves what was actually recorded. It also means an odometer jump at +resume can disagree with the speed channel; the app does not invent a value to +hide that disagreement. + +## 4. Speed + +`speed_instant__mps` is the canonical finalized speed channel, independent of +the cumulative odometer: + +```text +lap average speed = sum(speed[i] * active overlap seconds[i]) + / sum(active overlap seconds[i]) + +session average speed = the same calculation over all active intervals + +maximum speed = largest valid active instantaneous-speed record +``` + +Zero is a valid active speed. Missing, non-finite, negative, or FIT-incompatible +speed readings are excluded. If the recorded speed channel has no valid values, +both its average and maximum stay unavailable; it is not silently replaced by +odometer-derived speed. + +Finalized metric sources are fixed: speed always uses the retained +instantaneous column. There is no processing-mode setting. + +The main workout dashboard is a live presentation surface, not a finalized +workout aggregate. Its speed, pace, cadence, and pull-force cards use only the +ergometer's instantaneous fields; live estimated power uses instantaneous force +and cadence. Each card's secondary average/max and planned-target feedback use +the same instantaneous sources as the primary value. There is no fallback to an +averaged field. Sensor-card headings are explicitly labelled `Live` so these +values are not mistaken for finalized statistics. Finalized power uses those +same instantaneous inputs. + +### Why speed is not distance / active time + +The cumulative odometer and instantaneous speed are independent sensor +channels. Firmware behavior, sampling boundaries, and odometer changes around +pauses can make `distance / active time` differ from the time-weighted sensor +speed. KayakFit preserves both observations: the odometer owns distance and the +instantaneous speed channel owns speed. Therefore +`lap speed = lap distance / lap active time` is intentionally not an invariant. + +### Chart points + +The graph and hover readouts show the raw active sensor points without a moving +average. For long workouts, the Canvas selects real recorded boundary and +extreme points to fit the available pixels; it never replaces them with +calculated averages. FIT records and session/lap aggregates use the same +unsmoothed source points. Every time-axis point uses the row's canonical CSV +sample endpoint, which is also the timestamp written to its FIT sensor record. + +Because points sit at their window ends, each active run's line additionally +begins at the run's start boundary: the timer START on the time axis and the +cumulative active distance preceding its first sample window on the distance +axis. This run-start anchor is a drawn lead-in rather than a recorded point, so +the visible span of each run matches its lap active time and distance instead of +dropping the first window (and the resume odometer jump credited to it). It +mirrors the metric-free FIT run-start anchor and does not affect hover values, +statistics, or totals (D020). + +## 5. Other active averages + +Only active interval overlaps participate in heart-rate, cadence, and estimated +power aggregates. Summary/FIT cadence and the summary pull-force chart use the +recorded instantaneous protocol fields. Heart rate has one recorded source. + +- Missing or zero readings mean no valid signal and are excluded. +- Averages are weighted by active overlap seconds. +- Maximum is the largest valid active reading. +- Missing-HR active time remains visible as `No HR signal` in the zone chart. + +Power is estimated at every retained record: + +```text +power W = pull force N * configured pull length m * cadence spm / 60 +``` + +Cadence and pull force use `cadence_instant__spm` and +`pull_force_instant__n` from the same ergometer packet. Lap/session average +power is the active-time-weighted mean of those per-record estimates, not the +product of whole-lap/session averages. Maximum power is the largest valid +instantaneous-point estimate. The saved pull length makes historical power +processing reproducible; no metric-source preference is needed. + +## 6. Free-workout laps + +Each exact contiguous active run remains one FIT timer run. For presentation, +consecutive runs separated by less than five inactive seconds are grouped into +one free-workout lap. A gap of five seconds or more starts a new lap when +activity resumes. Inactive rows are not lap records and do not enter averages; +a grouped lap can therefore have more elapsed time than active time without its +averages being lowered. An inactive recording prefix or tail remains part of +session elapsed time and the graph bounds. + +## 7. Planned-workout laps + +For planned workouts, performed boundaries in `steps.json` define laps. New +sidecars contain only `index`, `type`, `label`, `start_elapsed_s`, and +`end_elapsed_s`. Bounds use canonical session elapsed time. Steps must start at +zero, be consecutive, and cover the complete captured session; epoch bounds +and raw device elapsed bounds are unsupported. + +- Time advances through warm-up, work, rest, and cool-down using elapsed time. +- `active_paddling` still selects active overlap inside every step. +- A person who paddles slowly through rest contributes active distance and + metrics to the rest lap. +- A person who stops during warm-up, rest, or cool-down contributes elapsed but + not active time for that interval. +- A fully inactive step remains visible with elapsed time, zero active time and + distance, and blank active statistics. +- A record interval crossing a step boundary is split proportionally by overlap + for time, distance, and weighted metrics. The FIT record itself is emitted + only once. + +The final recorded step owns the captured timeline tail so a naturally +completed plan conserves all recorded active time and distance. + +## 8. FIT construction + +The generated FIT contains: + +- timer `START`/`STOP_ALL` events around every exact active run; +- one sensor record per positive-duration active CSV window; +- active-only cumulative distance; +- unsmoothed recorded instantaneous speed and cadence; +- valid active HR and estimated power derived from instantaneous cadence/force; +- meaningful-pause free laps or performed-step planned laps; +- complete session elapsed time and active timer time; +- lap/session distance, averages, maxima, and one activity summary. + +Summary and FIT consume the same finalized objects and metrics, so the viewer +does not recalculate encoded values independently. + +## 9. Synthetic GPS and Strava + +Strava does not always honor FIT timer fields for indoor kayaking. KayakFit maps +active-only cumulative distance onto a fixed two-kilometre out-and-back line at +Hazewinkel. Each active run starts with a metric-free position anchor at its +previous active distance. + +No movement record is emitted during a pause, and idle odometer changes cannot +move the synthetic route. This route is compatibility data, not a claim that +the indoor ergometer measured GPS coordinates. + +Strava may still recalculate distance, moving time, speed, rounding, or lap +presentation. To audit KayakFit itself, compare the summary with decoded FIT +session and lap messages; those are regression-tested for parity. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..fda3fc3 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,342 @@ +# Architecture decisions + +This is an ADR-lite record of decisions that shape KayakFit. Each decision +states the current policy and why it exists. Open defects in an implementation +are tracked separately in [ROADMAP.md](ROADMAP.md); a decision being accepted +does not imply that every current code path satisfies it perfectly. + +## D001 — CSV is the canonical durable observation log + +- **Status:** Accepted +- **Decision:** Stream every valid ergometer response to semicolon CSV and retain + valid stationary and repeated rows. Do not segment, deduplicate, or discard + low-speed observations in `CsvReader`. +- **Rationale:** Capture and interpretation have different failure modes. A raw, + append-oriented timeline can be recovered after a crash and reprocessed when + segmentation improves. Global payload deduplication previously deleted + legitimate repeated samples and filtering stationary rows erased pauses. +- **Consequences:** CSV contains inactive periods that do not appear as FIT + records. Consumers must use finalization rather than raw row count or raw + first/last timestamps as workout duration. + +## D002 — Ergometer activity, not heart rate, owns workout timing + +- **Status:** Accepted +- **Decision:** `active_paddling` is required; `1` selects active represented + windows and `0` selects inactive windows. HR notifications update a + latest-value cache but never create rows or extend the workout. +- **Rationale:** The ergometer directly signals whether the athlete is paddling. + HR can begin before paddling and continue through recovery, producing the + unwanted prefix/tail that motivated the branch. +- **Failure policy:** A missing or invalid activity value makes the workout + invalid; no other metric substitutes for it. +- **Consequences:** Summary duration, FIT timer events, active averages, and + chart record domains are anchored to the ergometer signal. + +## D003 — FIT and summary share one finalized workout domain + +- **Status:** Accepted +- **Decision:** `app.finalized_workout.finalize_workout()` is the sole owner of + active record indices, exact active runs, presentation/FIT laps, chart breaks, + total timer time, elapsed time, and distance. `FitExporter` and + `compute_summary()` consume the same result. +- **Rationale:** Independent FIT, summary, and completion calculations previously + disagreed on pauses, tails, repeated rows, distance, and averages. +- **Verification:** `tests/test_pipeline.py` decodes a real FIT and compares its + session/lap fields with `WorkoutSummary`. +- **Verification:** Planned samples are intersected with step intervals and a + single sample can contribute weighted time, distance, and metrics to adjacent + laps without duplication in the FIT record stream. + +## D004 — Metric aggregation is domain-specific and shared + +- **Status:** Accepted +- **Decision:** `app.workout_metrics.compute_finalized_metrics()` produces one + session/lap aggregate set consumed verbatim by FIT and summary; + `app.stats.aggregate_metrics()` defines each slice's arithmetic. +- **Rules:** + - HR, instantaneous cadence, and instantaneous-input estimated power average + only valid positive readings. + - These averages use canonical represented sample duration. + - Average speed is the represented-time-weighted mean of valid active + `speed_instant` readings (see D017). + - Maximum speed is the largest valid active instantaneous-speed reading. + - Every reported average/maximum pair satisfies `average <= maximum` before + FIT encoding or UI rounding. + - FIT speed records use that same unsmoothed recorded series. + - Integer FIT/summary averages use nearest-integer rounding, not truncation. + - FIT-compatible limits filter values that cannot be encoded truthfully. +- **Rationale:** Generic column means and independently repeated calculations + created multiple sources of truth. +- **Presentation:** Legend aggregates come from `WorkoutSummary`; live active + distance/time and speed use the same accumulator as finalization. Summary + splits show finalized distance and millisecond timing without redistributing + rounded display units. + +## D005 — Capture persists the canonical processing timeline + +- **Status:** Accepted +- **Decision:** Capture writes `session_elapsed__s` and `sample_duration__s` for + every row. The row represents + `[session_elapsed - sample_duration, session_elapsed]`; `timestamp` is that + endpoint on one epoch origin. Raw device elapsed/window/timestamp fields are + diagnostics and never processing fallbacks. +- **Capture rule:** Advancing raw elapsed moves the canonical endpoint by its + delta. A stall/reset rebases to monotonic receipt time. Effective duration is + clipped to avoid overlap, leaving all uncovered time as pause time. A first + elapsed value of zero is a valid zero-duration observation. +- **Polling rule:** Poll commands follow fixed one-second monotonic deadlines. + Command-write time is included in the period, and a late command resumes from + a new deadline instead of causing catch-up bursts. This avoids systematic + drift while leaving delayed device responses safe. +- **Rationale:** The raw counter can stall or reset after reconnect. Persisting + the normalized result once makes final processing strict, simple, and fully + reproducible without recreating capture-time state. +- **Presentation decision:** Summary chart bounds span elapsed zero through the + final canonical session endpoint, including inactive prefixes and tails. Active + points are plotted at the start of their represented intervals. +- **Boundary policy:** Planned laps receive the exact overlap between the sample + window and each performed step. Time-weighted metrics and distance are split + by the same overlap fraction. +- **Validation:** `ProgramRunner` re-checks the same monotonic, non-overlapping + invariant in integer milliseconds, matching capture and finalization. The + canonical fields are millisecond-derived, so comparing float seconds directly + once rejected an exactly-filled window (`2.251 - 2.0 == 0.2509999999999999`, + just under a legitimate `0.251` s window) and crashed the live BLE callback. + Rounding both sides to milliseconds removes that false positive while still + rejecting a genuine overlap. + +## D006 — Free and planned workouts share activity selection but not grouping + +- **Status:** Accepted +- **Decision:** Activity selection is independent of the plan. After active + records are selected: + - free workouts group exact active runs across gaps shorter than the fixed + five-second meaningful-pause delay; + - planned workouts group records by performed warm-up/work/rest/cooldown step. +- **Rationale:** A plan describes user-facing structure, not permission to count + idle samples. At low cadence the ergometer can briefly clear its activity flag + between strokes, which must not create one-second free laps. Conversely, + movement pauses should not fragment a planned work step into meaningless laps. +- **Consequences:** Planned splits show elapsed and active time separately. A + fully inactive rest remains visible with blank averages. Free-lap elapsed time + may include short inactive gaps, while active time and averages remain exact. + The live free-lap indicator is hidden during planned workouts. + +## D007 — Per-workout metadata makes historical processing reproducible + +- **Status:** Accepted +- **Decision:** Save processing inputs in `metadata.json` and performed planned + boundaries in `steps.json` inside each workout directory. Export and summary + use only those recorded settings. +- **Captured inputs:** Pull length and HR zone configuration. Metric channels + are fixed by the v1 processing contract, not stored as user preferences. + Activity timing has no user-configurable threshold or gap. +- **Rationale:** Changing current settings must not retroactively change an old + workout's power, segmentation, or zones. +- **Failure policy:** Missing, incomplete, malformed, or unsupported metadata is + rejected as an invalid workout. A planned workout without a complete valid + performed-step timeline is rejected; it is never reclassified as free. + +## D008 — Power is estimated from force and cadence + +- **Status:** Accepted +- **Decision:** Use + `power_W = pull_force_N × pull_length_m × cadence_spm / 60` through + `app.power.estimate_power()`. +- **Default calibration:** `pull_length_m = 0.600`, constrained to 0.3–1.2 m. +- **Rationale:** The ergometer does not expose a dedicated power sensor field. + Using instantaneous force and cadence keeps every surface tied to the same + recorded points; 0.600 slightly lowers estimates from the former 0.623 + calibration. +- **Consequences:** KayakFit's maximum is a highest one-second estimate, not the + ergometer display's within-stroke peak. Calibration details live in + [POWER_MODEL.md](POWER_MODEL.md). +- **Consequences:** Pull length is passed explicitly to live metrics, summary, + and FIT processing. There is no process-global calibration state. Historical + workout metadata remains unchanged. + +## D009 — Derived FIT measurements require an explicit source and purpose + +- **Status:** Accepted +- **Decision:** Emit real device-derived metrics plus a deterministic synthetic + GPS track along the 2 km Hazewinkel course. Each position is calculated from + the same finalized active-only cumulative distance written to its FIT record. + Emit one metric-free anchor at each active-run START with the cumulative + distance preceding its first sample window. Do not generate altitude, + calories, arbitrary coordinates, or other unsupported measurements. +- **Rationale:** Strava recalculates the kayaking overview's moving time from + record positions and otherwise spans inactive timestamp gaps, even when FIT + timer and lap summaries are correct. Tying every position to the authoritative + active distance lets Strava identify pauses without creating an independent + distance or timing domain. +- **Consequences:** The displayed route is compatibility data, not a claim that + the indoor ergometer captured real GPS. Inactive odometer changes and paused + rows cannot advance the route. The GPS span from an anchor to its run's last + sensor record equals that run's authoritative timer time. + +## D010 — Reliability state fails closed + +- **Status:** Accepted +- **Decision:** Background operations produce one typed terminal result. CSV + metadata, performed steps, and recovery state use durable atomic JSON writes. + A missing required sidecar is partial output, never successful output. +- **Decision:** Only complete v1 ergometer packets reach recording callbacks. + Configuration is validated once through the shared GUI/worker validator; + invalid explicit values are not silently repaired. +- **Consequences:** Automatic FIT generation requires a successful recording + result. Partial CSV data remains recoverable and visible to the user. +- **Consequences:** The ergometer can be configured to show its own energy field, + but KayakFit does not copy or calculate calories for summary/FIT. + +## D011 — FIT replacement is atomic + +- **Status:** Accepted +- **Decision:** Build to a sibling `.fit.tmp` and replace the destination only + after `fit_tool` completes successfully. +- **Rationale:** Re-exporting an old workout must not destroy a valid FIT if the + new conversion fails midway. +- **Verification:** Regression tests assert preservation of an existing FIT on + failed export. + +## D012 — Thread boundaries are explicit + +- **Status:** Accepted +- **Decision:** BLE/session work and export/upload work run in background worker + threads. Workers send structured events through `WorkerManager`, which + schedules all Tk mutations on the GUI thread. +- **Rationale:** BLE, filesystem, FIT, OAuth, and HTTP operations can block. Tk + is not thread-safe. +- **Consequences:** Stop and manual step advance use thread-safe events. Shutdown + is bounded. Event dictionaries are currently loose and should become typed + records over time. + +## D013 — Strava upload acceptance is not completion + +- **Status:** Accepted, transaction persistence incomplete +- **Decision:** Treat a timed-out processing poll as `pending`, not as a + confirmed activity and not as a rejection. Refresh tokens proactively and + persist rotated credentials even when upload later fails. +- **Security:** OAuth uses a loopback-only callback, per-flow cryptographic state, + constant-time state comparison, and OS credential-vault storage when + available. +- **Known qualification:** Pending upload IDs are not persisted, so the app + cannot yet resume the transaction after restart. + +## D014 — Use a small stdlib Table instead of Polars + +- **Status:** Accepted +- **Decision:** `app.table.Table` provides the limited column/row operations the + pipeline needs. +- **Rationale:** Removing a dataframe dependency reduces packaged application + size and startup complexity. +- **Consequences:** Add focused operations to `Table` only when they are used by + the domain; do not recreate a general dataframe API. + +## D015 — Defensive parsing has bounded graceful degradation + +- **Status:** Accepted +- **Decision:** Malformed independent units are skipped with a precise warning + where truthful output remains possible; hard failure occurs when too little + usable input remains. +- **Examples:** + - one malformed program file does not hide other programs; + - one malformed CSV row can be skipped, but predominantly corrupt input fails; + - malformed planned-workout timelines fail closed because silently switching + lap semantics would produce a different workout; + - program repeat depth, operations, and expanded steps are capped. +- **Boundary:** Persistence failures and failures that would falsely claim a + successful workout are not candidates for silent degradation. + +## D016 — Strava authorization is one atomic credential-vault record + +- **Status:** Accepted +- **Decision:** Store client ID, client secret, access token, refresh token, and + token expiry together in one OS credential-vault entry. All five fields are + required before any UI or upload path reports Strava as connected. +- **Rationale:** The credential vault survives deletion of `~/KayakFit` while + `config.yml` does not. Splitting refresh-critical state across both stores can + restore tokens without their client ID or expiry and falsely show Connected. +- **Consequences:** A complete connection is restored after the settings folder + is recreated. Disconnect removes the vault entry. Incomplete pre-release + entries are deleted rather than migrated. + +## D017 — Recorded instantaneous speed is the canonical speed channel + +- **Status:** Accepted +- **Decision:** Raw chart points, time-weighted lap/session averages, maxima, and + FIT record speed use valid active `speed_instant` readings. Cumulative + odometer distance is a separate recorded channel and is never capped or + rewritten using speed. +- **Rationale:** Odometer changes around pauses can make + `distance / active time` disagree with the recorded instantaneous-speed + series. Keeping the two observations independent is reproducible and avoids + inventing corrections that are absent from the sensor data. +- **Consequences:** + - `lap distance / lap active time` can differ from reported average speed. + - A valid time-weighted average cannot exceed its raw maximum; constant speed + legitimately produces equality rather than a strictly lower average. + - If the recorded speed channel has no valid value, average and maximum stay + unavailable instead of falling back to odometer-derived speed. + - Missing odometer readings still bridge over their pending active intervals, + independently from speed. + +## D018 — Metric channels are fixed by metric + +- **Status:** Accepted +- **Decision:** There is no global metric-source setting. Summary and FIT use + instantaneous protocol points for speed and cadence, and the summary uses + instantaneous pull force. The main-window cards, their average/max previews, + and planned target feedback consistently use instantaneous speed, pace, + cadence, and pull force without an averaged-field fallback. Live and + finalized power both use instantaneous force and cadence with the recorded + pull-length calibration. Heart rate and distance each have one source field. +- **Rationale:** Workout review and export retain the recorded instantaneous + sensor points, and physical testing showed that averaged protocol fields make + the live cards read lower than the ergometer display. A single instantaneous + live policy keeps cards, previews, and target feedback internally consistent. +- **Consequences:** Main-window sensor cards and their secondary average/max use + instantaneous device fields. Summary graphs, aggregates, and FIT records use + the same instantaneous sensor channels. Changing app settings cannot + reinterpret a workout's metric channels. + +## D019 — Chart-line breaks use the meaningful-pause delay in every workout mode + +- **Status:** Accepted +- **Decision:** The summary chart restarts its line only at meaningful-pause + group boundaries (`app.finalized_workout._chart_breaks` over + `_group_free_segments`), for planned and free workouts alike. Exact active runs + less than the fixed five-second delay apart are drawn as one continuous line. +- **Rationale:** Planned charts previously broke the line at every exact activity + run. At high cadence the ergometer can report a one-second window across a + two-second elapsed advance, leaving a one-second uncovered gap that closes an + exact run even though paddling never stopped. Breaking the line at each such + gap fragmented a continuous work interval into disconnected pieces. Reusing the + meaningful-pause grouping already applied to free workouts keeps the line + faithful to the effort. +- **Consequences:** Only chart-line continuity changes. Exact runs, FIT timer + events, active time, distance, and planned/free laps are unchanged; the + uncovered second still counts as pause, not active time. Planned step + boundaries continue to render as chart bands, not line breaks. A real pause of + five seconds or more still starts a new line. + +## D020 — Summary chart lines begin at each run's start boundary + +- **Status:** Accepted +- **Decision:** On both the time and distance x-axes, the summary chart starts + each active run's line at the run's start boundary: the FIT timer START (the + window start time) and the cumulative active distance preceding the run's + first sample window. `app.summary` exposes these as `series_anchor_t` and + `series_anchor_distance`; `gui.summary_window` prepends the anchor at each line + break using the first record's value. +- **Rationale:** Sensor records are plotted at the end of their sample window, so + without an anchor each run's line began one window in. In distance mode this + understated every segment's span versus the lap table and drew a resume + odometer jump — credited to the first active record after a pause — as empty + space between segments. Anchoring each run at its start makes the drawn span + equal the lap distance and active time and mirrors the metric-free FIT + run-start anchor already emitted for Strava (D009). +- **Consequences:** Presentation only. The anchor is a drawn lead-in, not a + record: the `series_*` value arrays, hover snapping, statistics, laps, totals, + and FIT output are unchanged. Where a distance reading is missing the anchor is + omitted and the line falls back to starting at its first point. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..08f9bbd --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,204 @@ +# Build, deployment, and release guide + +KayakFit is a local desktop application. It has no server deployment. Delivery +means building platform-native PyInstaller artifacts and publishing them through +a tagged GitHub Release. + +`main` is the only releasable branch. Feature branches may produce manual test +artifacts, but they must not publish releases. + +## Deliverables + +| Platform | Build output | Published archive | +|---|---|---| +| macOS 14 Apple Silicon | `dist-mac/KayakFit.app` | `KayakFit-macOS-arm64.zip` | +| Windows | `dist-win\KayakFit.exe` | `KayakFit-Windows.zip` | + +The cross-platform build definition is `KayakFit.spec`. It bundles application +modules, assets, preset programs, BLE/FIT dependencies, CustomTkinter data, and +keyring backends. macOS and Windows builds must run on their target operating +system; PyInstaller does not cross-compile these deliverables. + +## Pre-build validation + +Before creating an artifact: + +```bash +uv lock --check +uv sync --locked +uv run ruff check . +uv run mypy . --strict +uv run pytest +``` + +Also review the outstanding release-relevant items in [ROADMAP.md](ROADMAP.md) +and complete the manual checks in [TESTING.md](TESTING.md). + +## Local macOS build + +Run on macOS from the repository root: + +```bash +./pyinstaller_build.sh +``` + +The script clears only `build-mac/` and `dist-mac/`, creates `.nosync` markers, +installs the build dependency group, and invokes `KayakFit.spec`. + +Launch the result with: + +```bash +open dist-mac/KayakFit.app +``` + +For console-visible startup diagnostics: + +```bash +./dist-mac/KayakFit.app/Contents/MacOS/KayakFit +``` + +Verify that the bundle contains Tcl/Tk frameworks and resource data. The release +workflow checks these paths explicitly: + +```text +Contents/Frameworks/Tcl +Contents/Frameworks/Tk +Contents/Resources/tcl9 +Contents/Resources/_tcl_data +Contents/Resources/_tk_data +``` + +The app bundle declares Bluetooth usage descriptions and uses bundle identifier +`com.kayakfit.app`. + +## Local Windows build + +Run from Command Prompt on Windows: + +```bat +pyinstaller_build.bat +``` + +The result is the single windowed executable `dist-win\KayakFit.exe`. It writes +diagnostics to the normal KayakFit log because a packaged windowed executable +has no console. + +## Testing CI builds without releasing + +Run the `Build & Release` workflow manually from GitHub Actions. A +`workflow_dispatch` run builds both platforms and uploads workflow artifacts, +but does not create or modify a GitHub Release. + +Manual workflow builds use version `0.0.0-dev`. Tagged builds derive the version +from the tag through `KAYAKFIT_VERSION`. + +## Versioning + +KayakFit follows semantic versioning and is currently in initial `0.x` +development. Keep these three values aligned: + +- `app/__init__.py::__version__`; +- `pyproject.toml` project version; +- release tag `MAJOR.MINOR.PATCH` (for example, `0.2.0`). + +While the project remains below `1.0.0`: + +- internal bug fixes normally bump PATCH; +- new features or breaking config/CSV/FIT changes bump MINOR; +- promotion to a stable compatibility commitment becomes `1.0.0`. + +Version changes belong in a reviewed commit on `main` before the release tag is +created. + +## Publishing a release + +After the intended feature branches have been reviewed and integrated into +`main`: + +1. Confirm CI is green on `main`. +2. Confirm versions match in `app/__init__.py` and `pyproject.toml`. +3. Run or review platform smoke tests. +4. Create the matching annotated or lightweight tag on the selected `main` + commit. +5. Push only that tag when release publication is authorized. + +Example: + +```bash +git switch main +git pull --ff-only +git tag 0.1.0 +git push origin 0.1.0 +``` + +Pushing a bare semantic-version tag such as `0.1.0` triggers +`.github/workflows/build-release.yml`. The workflow: + +1. builds macOS on `macos-14` using an official Python with Tcl/Tk; +2. verifies the macOS Tcl/Tk bundle; +3. builds Windows on `windows-latest`; +4. uploads both build artifacts; +5. creates or updates the tag's GitHub Release and attaches both ZIP files. + +Publishing through the GitHub Release UI does not trigger a build. Release +artifacts are produced only by an authorized bare semantic-version tag on +`main`. + +## Release verification + +Download the archives from the Release page rather than testing only workspace +outputs. On each platform verify: + +- the archive extracts successfully; +- the app starts without a source checkout or Python environment; +- the displayed/file version matches the release tag; +- bundled assets and preset workouts are present; +- Bluetooth permission and scanning work; +- a short workout can be recorded, summarized, and exported to FIT; +- the packaged app can read/write `~/KayakFit` data and access credentials; +- logs are written and contain no secrets. + +Keep the previous release available until smoke verification completes. + +## Signing and platform warnings + +Code signing and notarization are not currently configured. Downloaded macOS +builds can show a Gatekeeper “unidentified developer” warning, and Windows can +show a SmartScreen warning. These warnings are an acknowledged deployment gap, +not evidence that the archive is corrupt. + +Do not document unsigned artifacts as trusted or instruct users to disable +platform security globally. When signing is introduced, record certificate +ownership, secret handling, notarization, timestamping, and verification here +without committing credentials. + +## Failure and rollback + +If a release build or smoke test fails: + +1. Do not move or reuse the failed version tag for different source history. +2. Keep the last known-good release published. +3. Record the failed platform, workflow run, logs, and reproduction details. +4. Fix through a reviewed branch and issue a new patch version/tag. +5. Mark a bad GitHub Release as a prerelease or remove its downloadable assets + only with maintainer authorization. + +Application rollback means reinstalling the prior desktop artifact. User CSV, +FIT, metadata, program, and configuration files live outside the app bundle and +must not be deleted by an application rollback. If a release changes a durable +format, document forward/backward compatibility and migration behavior in +[DECISIONS.md](DECISIONS.md) before publication. + +## Security and operational boundaries + +- GitHub Actions release permissions are write-scoped only for the release + workflow; ordinary CI uses read-only repository contents. +- OAuth tokens and client secrets must never be embedded in builds, logs, test + fixtures, workflow artifacts, or release notes. +- Runtime credentials use the OS keychain where available, with the documented + restricted fallback behavior. +- Do not upload real user workout data as CI artifacts. +- Build directories and archives are generated outputs and are not committed. + +See `CONTRIBUTING.md` for the repository governance and pull-request workflow; +this document is the operational build/release reference. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..0fac4cb --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,161 @@ +# Development guide + +This guide covers day-to-day development of KayakFit from source. Read +[AGENTS.md](AGENTS.md) for repository invariants, [ARCHITECTURE.md](ARCHITECTURE.md) +for runtime design, and [STYLE_GUIDE.md](STYLE_GUIDE.md) before changing core +workout behavior. + +## Supported environment + +KayakFit targets Python 3.14 and ships on macOS and Windows. The project uses +`uv` for Python installation, dependency resolution, virtual-environment +management, and command execution. + +Required tools: + +- Git; +- `uv`; +- Bluetooth hardware for device-level manual testing; +- platform build tools only when producing a packaged application. + +Runtime dependencies and development tools are declared in `pyproject.toml`. +Exact versions are pinned in `uv.lock`. `requirements.txt` is a generated pip +fallback and must not be edited manually. + +## Initial setup + +From the repository root: + +```bash +uv sync --locked +uv run python kayakfit_gui.py +``` + +`uv sync --locked` creates `.venv`, installs the default development group, and +fails if `pyproject.toml` and `uv.lock` disagree. Use plain `uv sync` only when +intentionally changing dependencies and updating the lock. + +To use the environment without prefixing every command: + +```bash +source .venv/bin/activate +``` + +On Windows PowerShell, activate with `.venv\Scripts\Activate.ps1`. + +## Branch workflow + +Keep `main` releasable and make changes on a feature or fix branch: + +```bash +git switch main +git pull --ff-only +git switch -c feature/short-description +``` + +Use readable prefixes such as `feature/`, `fix/`, or `chore/`. Do not merge, +push, rebase shared work, or synchronize with a remote unless explicitly +authorized. Integration into `main` is reviewed through Claude for this +repository, so branch documentation and commit messages must make the intent +recoverable without relying on chat history. + +Before committing: + +1. Inspect the relevant architecture and decision documents. +2. Make the smallest coherent change. +3. Add regression coverage for changed behavior. +4. Update user-facing and architectural documentation where applicable. +5. Run the checks in [TESTING.md](TESTING.md). +6. Review `git diff`, `git diff --check`, and the staged files. + +## Running and debugging + +Run the source application with: + +```bash +uv run python kayakfit_gui.py +``` + +Useful runtime boundaries: + +- `kayakfit_gui.py` starts the Tk application. +- `workout_worker.py` owns the background recording worker entry point. +- `export_worker.py` owns FIT conversion and optional upload work. +- `app/workout_session.py` coordinates BLE capture and durable recording. +- `app/finalized_workout.py` is the shared FIT/summary workout domain. +- `gui/worker_manager.py` marshals worker events onto the GUI thread. + +Logs are written beneath the user's `KayakFit/logs` directory. Packaged +windowed builds do not have a console, so the rotating log is the primary +diagnostic source. + +User data is stored outside the repository under `~/KayakFit` on macOS and +`C:\Users\\KayakFit` on Windows. Tests must not depend on or modify that +real directory; use temporary paths and patch home/config boundaries. + +## Dependency changes + +Add or update dependencies through `pyproject.toml`, then regenerate committed +derived files: + +```bash +uv lock +uv sync --locked +uv export --format requirements-txt --no-hashes --all-groups -o requirements.txt +``` + +Review both `uv.lock` and `requirements.txt`. CI verifies that the lock matches +the project definition and that the generated requirements fallback matches the +lock. + +Runtime packages belong in `[project].dependencies`. Ruff, mypy, pytest, and +type stubs belong in the `dev` dependency group. PyInstaller-only dependencies +belong in the `build` group so they are not installed or bundled unnecessarily. + +## Working on workout processing + +Preserve these ownership boundaries: + +```text +BLE packets -> CSV observation log -> CsvReader -> FinalizedWorkout + -> FIT + -> summary +``` + +- CSV capture retains valid stationary and repeated observations. +- The required ergometer `active_paddling` signal owns activity membership; + heart rate never extends the activity. +- `finalize_workout()` owns records, timer runs, laps, active distance, and + totals used by FIT and the summary. +- Canonical samples represent `[session_elapsed - sample_duration, + session_elapsed]`; a zero-duration observation owns no activity time. +- Planned samples may be split across exact performed-step boundaries. +- Summary charts show raw source points and must not redefine statistics. + +Changes to this path require invariant tests and decoded-FIT parity coverage, +not only unit tests of a helper. + +## GUI and concurrency rules + +Tk and CustomTkinter widgets may only be mutated on the GUI thread. BLE, +filesystem, FIT, OAuth, and HTTP work belongs in workers. Workers report +structured events through `WorkerManager`; a thread must never call a widget +directly. + +The GUI is composed from mixins. Shared attributes are declared for type +checking in `gui/_mixin_base.py`, but that file does not create runtime state. +Initialize shared state in `gui/main_gui.py` and avoid widening the implicit +mixin surface when an explicit controller or service boundary is practical. + +## Documentation responsibilities + +- End-user behavior: `README.md` and [FEATURES.md](FEATURES.md). +- Runtime/data flow: [ARCHITECTURE.md](ARCHITECTURE.md). +- Design choice or invariant: [DECISIONS.md](DECISIONS.md). +- Planned or incomplete work: [ROADMAP.md](ROADMAP.md). +- Code conventions: [STYLE_GUIDE.md](STYLE_GUIDE.md). +- Build and release operation: [DEPLOYMENT.md](DEPLOYMENT.md). + +Keep documentation factual and based on the current code. Do not mark a feature +implemented merely because a function or button exists; verify its complete +execution path and failure behavior. diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..f1580fc --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,257 @@ +# User-facing features + +This document records behavior that is present in the current codebase. It does +not describe aspirational functionality. Verification labels mean: + +- **Tested** — covered by automated processing/unit tests. +- **Implemented** — the complete code path exists, but no automated GUI, + hardware, or live-service test proves the full interaction. +- **Partial** — usable behavior exists with a known correctness or reliability + limitation tracked in [ROADMAP.md](ROADMAP.md). +- **Not implemented** — a nearby setting or device field may suggest the + feature, but the application does not provide it. + +## Setup and settings + +| Feature | Implementation | Status | +|---|---|---| +| First-run device wizard | `gui/setup_wizard.py` | Tested at the persistence boundary; asks for the printed device name, requires an ergometer selection, and saves both selected devices | +| BLE ergometer scan and selection | `gui/device_scanner.py`, `gui/config_window.py` | Tested; configured names match both platform and advertised BLE names | +| Optional standard BLE HRM scan | Heart Rate Service discovery in `gui/device_scanner.py` | Implemented | +| Device connection tests | `gui/device_scanner.test_connection()` | Partial: tests only the BLE link, not the expected service/protocol | +| Per-workout person and boat weights | main toolbar → workout worker → ergometer handshake | Implemented | +| Five-slot ergometer display configuration | `ConfigManager.DISPLAY_MAP`, settings, handshake | Implemented | +| Fixed reproducible metric policy | `CsvReader.read_all()`, `gui.metrics_format` | Tested; main-window cards, graph, summary, and FIT use instantaneous fields (D017, D018) | +| Automatic/manual HR zones | `app/stats.py`, settings, device-status UI | Tested | +| Logging level and interval | settings and `WorkoutSession` | Implemented | +| Sound cues and target grace period | settings, program panel | Implemented | +| Automatic Strava upload | settings and export worker | Implemented | +| System/light/dark appearance switch | `gui/main_gui.py` | Implemented; choice is persisted | + +Advanced reconnect, stale-data, inactivity-stop, and power-model values exist +in `config.yml` defaults but are not exposed as ordinary settings. The +ergometer handshake and activity-run segmentation have no user-facing tuning. + +The “Total Expended Energy” display choice configures the ergometer's own +screen. It does not enable calorie calculation in KayakFit. + +## Live workout experience + +| Feature | Implementation | Status | +|---|---|---| +| Start/stop button and Space shortcut | `gui/recording_lifecycle.py` | Implemented | +| Two-action stop confirmation | recording lifecycle | Implemented | +| Live time, distance, speed, pace, cadence, HR, pull force, and power | `gui/metrics_format.py` | Implemented with one selected-field policy | +| 200/500/1000 m pace selection | `format_pace()` and toolbar selector | Implemented | +| Live average/maximum previews | `RecordingLifecycleMixin._update_stat()` | Implemented; previews use the same instantaneous channels as their live cards | +| HR-zone tile, legend, and ranges | `gui/device_status.py` | Tested at pure-function level | +| Ergometer, HRM, Strava, battery, reconnect, and stale-data indicators | device-status and recording-lifecycle mixins | Implemented | +| Continue without unavailable HRM | `WorkoutSession` plus GUI prompt | Implemented | +| Keep the display awake | `app/keep_awake.py` | Implemented | +| Data-stall warning | `WorkoutSession._watchdog()` | Implemented | +| Automatic stop after prolonged inactivity | workout watchdog | Tested; inactivity follows `active_paddling`, not cumulative distance | +| Maximum six-hour recording guard | `workout_worker.py` | Implemented | + +BLE parsing, packet reassembly, and segmentation are tested without hardware. +Actual compatibility with a particular ergometer firmware, BLE adapter, HR +strap, or packaged operating-system build requires physical integration tests. + +## Free workouts and autopause + +- The ergometer's `active_paddling` signal is required and authoritative. +- Heart-rate notifications update the latest HR value but never create a row or + extend workout duration. +- Canonical `session_elapsed__s` and `sample_duration__s` define record windows + and total elapsed time; raw device timing remains diagnostic. +- Ergometer polling follows fixed one-second monotonic deadlines; BLE write time + does not accumulate into the interval and late writes do not trigger bursts. +- Every exact active run owns one FIT timer run and the active metric domain. +- The live pause/lap state, free laps, and summary chart segments in both workout + modes split only after five continuous canonical seconds of inactivity. Shorter + inactive or uncovered gaps do not lower active averages or break the chart + line, and the delay is not configurable. +- Missing or invalid activity fields are rejected rather than inferred from + other metrics. +- A forgotten free workout is automatically stopped and saved after the + configured inactivity interval. + +Status: **Tested**, including inactive HR prefixes/tails, free-workout laps, +event balance, strict activity validation, chart breaks, and active-only +distance rebasing. + +The BLE parser admits only complete v1 packets with valid activity, elapsed, +distance, and represented-duration fields. Repeated malformed input is shown as +a device-health problem and cannot become a CSV row. + +## Planned workouts + +Programs are JSON files loaded from `~/KayakFit/programs`. Bundled examples are +copied there once so users can edit them. + +Implemented behavior: + +- Warm-up, work, rest, cooldown, and effort steps with optional custom labels. +- Time-based, distance-based, and open/manual steps. +- Nested repeat blocks with depth, operation, and total-step limits. +- Current and next step, remaining time/distance, step progress, and overall + program progress. +- Right-arrow/manual next-step control. +- Compact pre-workout step/time/distance preview for the selected program. +- Phase-change, ending-soon, and completion sound cues. +- Targets for HR zone, 200/500/1000 m pace, estimated power, and cadence. +- Configurable grace period before the target badge changes state. +- Automatic workout stop when the last step completes. +- Durable `steps.json` timeline with canonical session-elapsed boundaries in the workout + directory. +- One summary split/FIT lap per recorded step, with separate elapsed and active + time. An inactive rest remains visible with blank active averages. +- Planned step bands on time-based summary charts. +- Summary chart lines stay continuous through brief (under five-second) inactive + or uncovered gaps, so a high-cadence effort is not fragmented into disconnected + segments; only a pause of five seconds or more starts a new line (D019). + +Status: **Tested**. The strict schema rejects unknown fields, duration kinds, +step types, targets, and non-integral repeat counts. Progression preserves exact +overshoot boundaries, and performed-step attribution splits sample windows. + +## Recording, recovery, and files + +Each workout can create: + +```text +/workout_/ + workout.csv durable raw observation timeline + metadata.json processing settings recorded with the workout + steps.json performed planned-step boundaries, when applicable + activity.fit processed activity +``` + +Recordings are organized under `~/KayakFit/workouts//`. + +Implemented behavior: + +- Semicolon-delimited CSV, one row per ergometer response. +- Flush after every row and periodic filesystem sync. +- Read-only protection after close where supported. +- Defensive CSV parsing with an exact versioned schema. +- Atomic metadata and planned-step replacement. +- Active-workout marker and recovery prompt after interruption. +- Post-workout card with summary, FIT, upload, and discard actions. + +Status: **Implemented**. CSV persistence failures stop recording with an explicit +partial-workout state, unique workout directories are reserved atomically, +discard is serialized with export while removing owned artifacts, and the +recovery marker uses durable atomic replacement. + +## FIT generation and summary + +The user-facing calculation rules and free/planned worked examples are in +[DATA_TRANSFORMATIONS.md](DATA_TRANSFORMATIONS.md). + +FIT output represents an indoor kayaking activity with: + +- Real active ergometer sensor records, plus one metric-free GPS compatibility + anchor at each active-run start. +- Timer START/STOP events for exact active runs. +- Planned-step laps or meaningful-pause free-workout laps. +- Session and lap distance, elapsed time, timer time, speed, heart rate, + cadence, and estimated power. +- A deterministic Hazewinkel out-and-back GPS track derived from finalized + active-only distance, allowing Strava to calculate overview moving time + correctly across pauses and include every run's first sample window. +- No calories, altitude, or arbitrary coordinates. +- Atomic destination replacement so a failed rebuild preserves an older valid + FIT. + +The summary provides: + +- Active distance plus active, elapsed, and pause time. +- Average/maximum speed, HR, and estimated power. +- Average cadence. +- Selectable speed, power, HR, cadence, and pull-force charts. +- Raw chart points and hover readouts, plus a power tooltip explaining its + instantaneous cadence/force inputs. +- Time or cumulative-distance x axis, raw-point hover values, and pause/step + visualization. In time mode, planned phases use one compact strip above the + metrics; short steps remain visible without repeated or overlapping labels. + Each run's line starts at its run-start boundary so its drawn time and distance + spans match the splits table rather than dropping the first sample window (D020). +- Time in HR zones. +- Free-workout laps or planned-step splits. + +The summary headline and split calculations use the same `FinalizedWorkout` and +immutable finalized metric result as FIT generation. Speed and cadence averages +are represented-time-weighted active instantaneous readings; peaks and FIT +records use those same raw channels. The pull-force chart and power inputs are +instantaneous (`app.speed_series`, D017/D018). Charts and hover readouts show +raw recorded points. +Every session/lap average is bounded by its maximum, and splits preserve +finalized centimetre/millisecond values rather than redistributing rounded +units. Decoded-FIT regression tests compare encoded fields with +`WorkoutSummary`, including complete elapsed bounds and removal of HR-only +metrics from active aggregates. + +Status: **Implemented**, with these explicit limitations: + +1. Step labels are shown in the summary but are not encoded as FIT lap names. +2. Pull force has no corresponding FIT session/lap aggregate. + +## Workout history + +- Lists the newest 200 CSV recordings recursively. +- Opens the workouts folder in Finder, Explorer, or the Linux file manager. +- Allows an external CSV or FIT file to be selected. +- Shows a compact `Plan`, `Free`, `Invalid`, or `FIT` badge beside each filename. + CSV mode comes from required versioned metadata; imported FIT files are not + guessed as planned or free. The first screenful is + mapped transparently for its final scrollable width before it is revealed, so + badges appear directly in their stable right-aligned position; older rows load + below it in responsive batches. +- Opens CSV summaries. +- Filters the loaded list by filename or workout type and supports Up/Down, + Enter, and Escape keyboard navigation. +- Converts CSV files to FIT. +- Uploads CSV or FIT files to Strava. + +Status: **Implemented**. History is ordered by modification time rather than the +recorded workout timestamp and has no pagination or delete action. + +## Strava + +- OAuth authorization-code flow using a loopback HTTP callback on + `127.0.0.1`, with manual pasted-code fallback. +- Cryptographic OAuth state validation. +- Connect/disconnect controls and connection indicator. +- Access-token refresh with rotated refresh-token persistence. +- Atomic OS credential-vault storage of the complete authorization when + available; owner-only YAML fallback. Incomplete credentials are disconnected. +- CSV-to-FIT conversion before upload. +- Stable upload `external_id` based on the FIT filename. +- Asynchronous processing polling and pending-state reporting. +- “View on Strava” after a confirmed activity ID. + +Status: **Tested with mocked HTTP behavior**, not against the live service. +Pending upload IDs are not persisted, so status polling cannot resume after a +restart without initiating a new upload workflow. + +## Diagnostics and resilience + +- Rotating application log under `~/KayakFit/logs`. +- Collapsible in-app activity log with a line cap. +- Native crash trace file through `faulthandler`. +- Tk callback exception logging for windowed builds. +- Bounded worker shutdown and stop-aware BLE reconnect. +- Atomic config and FIT replacement. + +Status: **Implemented**. Workers return structured success, cancellation, +partial-durability, failure, and pending-upload outcomes. + +## Explicitly not implemented + +- Real GPS capture or user-selectable routes; FIT export uses only the fixed + Hazewinkel Strava-compatibility track. +- Calorie calculation or FIT calorie totals. +- Cloud account synchronization other than Strava upload. +- Historical trends, personal records, or analytics across workouts. +- A built-in graphical workout-plan editor. +- Linux release packaging as a supported product target. diff --git a/docs/POWER_MODEL.md b/docs/POWER_MODEL.md new file mode 100644 index 0000000..87e3d6b --- /dev/null +++ b/docs/POWER_MODEL.md @@ -0,0 +1,53 @@ +# Power model and `pull_length_m` calibration + +The KayakFirst ergometer has no power sensor. Its display derives power from +flywheel-based pull force and stroke rate, and KayakFit reproduces that model +(see `app/power.py`, the single definition used by the live dashboard, the +summary window and the FIT exporter): + +``` +power (W) = pull_force (N) × pull_length (m) × cadence (spm) / 60 +``` + +Physics: work per stroke = force × pull distance (joules); strokes per second += cadence / 60; power = joules per second = watts. The only free parameter is +the **effective handle pull length** `L`. + +## Current calibration + +The shipped default is `L = 0.600 m`. Every live, chart, summary, and FIT power +value uses `pull_force_instant__n` and `cadence_instant__spm` from the same +recorded packet. This is 3.7% lower than the former 0.623 m calibration and +keeps power aligned with the other instantaneous sensor channels. + +Future workouts use the configured pull length, which defaults to 0.600 m. +Historical workouts continue to use the pull length saved in their +`metadata.json`. + +KayakFit's session/lap average is the represented-time average of the positive +per-record estimates. Its maximum is the highest recorded-point estimate. The +ergometer display can use a different aggregation or a sub-second within-stroke +peak, so its average and maximum need not match KayakFit exactly. + +## Refitting for another machine or firmware + +If your machine or firmware reads differently, refit `L`: + +1. Record a workout with KayakFit while noting the machine display's power at + a range of intensities (easy, steady, hard) — the more paired readings, the + better; vary cadence and force independently if you can. +2. For each paired reading, compute a length estimate from the model: + `L_i = 60 × display_power_W / (pull_force_N × cadence_spm)`. Use the + instantaneous CSV fields (`pull_force_instant__n`, + `cadence_instant__spm`) from that same moment. +3. Fit a single `L` across all points (a least-squares fit of display power + against `force × cadence / 60`, or simply the mean of the `L_i` values if + they are tight). Sanity-check the residuals: RMSE within a few watts means + the model holds for your firmware. +4. Set the result under `pull_length_m` in `~/KayakFit/config.yml`. Values + outside the sanity range **0.3–1.2 m** are treated as a config mistake and + use the calibrated default (see `normalize_pull_length` in `app/power.py`). + +The value is passed explicitly through live, export, and summary processing to +the same `app.power.estimate_power` formula without process-global state. All +surfaces supply instantaneous force and cadence. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..3346a71 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,59 @@ +# Roadmap + +This file contains only open work. Implemented behavior belongs in +[FEATURES.md](FEATURES.md), processing rules in +[DATA_TRANSFORMATIONS.md](DATA_TRANSFORMATIONS.md), and architectural rationale +in [DECISIONS.md](DECISIONS.md). + +Priority meanings: + +- **P0 / Critical** — invalid workout output, silent data loss, or a destructive + race. No known P0 issue is currently open. +- **P1 / Recommended** — material correctness, reliability, or maintainability + risk. +- **P2 / Optional** — usability, performance, or architectural improvement. + +## P1 — Persist pending Strava transactions + +- **Location:** `app/strava_uploader.py::upload_file`, recovery and upload UI. +- **Problem:** When Strava accepts an upload but processing outlasts polling or + the app shuts down, the upload ID is not retained. +- **Next step:** Persist the upload ID, source FIT, external ID, account context, + and acceptance time. Resume polling after restart without repeating the POST. + +## P1 — Make device tests protocol-aware + +- **Location:** `gui/device_scanner.py::test_connection`. +- **Problem:** The settings test proves only that a BLE address accepts a + connection; an incompatible device can pass and fail when a workout starts. +- **Next step:** Verify the standard heart-rate service and characteristic. For + the ergometer, perform a safe characteristic or capability check that does + not reset a workout. + +## P2 — Improve workout history + +- **Location:** `gui/history_window.py`. +- **Problem:** History is limited to 200 entries, sorted by modification time, + and has no pagination, date/metric filters, or in-window deletion. Filename + and workout-type search only filters the bounded list already loaded. Copies + can reorder entries and older recordings can disappear from the list. +- **Next step:** Sort by recorded metadata time and load entries incrementally + with richer filtering and deletion controls. + +## P2 — Expand integration and failure-injection tests + +- **Location:** `tests/`, CI, and packaged-build workflows. +- **Problem:** GUI interaction, thread races, storage failures, clock changes, + long-chart performance, physical devices, and external services remain + lightly exercised. +- **Next step:** Add controller-level dependency injection, persistence-failure + tests, temporal invariant checks, packaged-app smoke tests, and optional + hardware/service suites. + +## P2 — Align documentation and exposed settings + +- **Location:** `README.md`, settings UI, and `ConfigManager.DEFAULTS`. +- **Problem:** Some advanced values exist only in YAML, and ergometer display + fields can be mistaken for values recorded or exported by KayakFit. +- **Next step:** Expose only safe, understandable controls and clearly separate + device-display configuration from recorded and exported metrics. diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md new file mode 100644 index 0000000..9e08644 --- /dev/null +++ b/docs/STYLE_GUIDE.md @@ -0,0 +1,202 @@ +# Coding and documentation style + +The executable configuration in `pyproject.toml` is authoritative when this +guide and tooling disagree. Ruff and strict mypy must pass with zero errors. + +## Required checks + +Run from the repository root: + +```bash +uv run ruff check . +uv run mypy . --strict +uv run pytest +``` + +The complete suite runs in one pytest process. + +## Python conventions + +- Python target: 3.14 or newer as declared in `pyproject.toml`. +- Maximum line length: 100 characters. +- Import groups: standard library, third-party, then local `app`/`gui`, with a + blank line between groups and alphabetical ordering within each group. +- Public class names use `CapWords`; modules, functions, methods, parameters, + and variables use `snake_case`; constants use `UPPER_SNAKE_CASE`. +- Internal helpers use a leading underscore. +- Worker entry modules follow `_worker.py` with + `run__worker()`. +- Prefer modern built-in generic syntax: `list[str]`, `dict[str, Any]`, and + `tuple[int, int]`. + +## Types + +All new or changed application functions and methods require parameter and +return annotations. Strict mypy means: + +- no untyped or incompletely typed definitions; +- no bare `dict`, `list`, `Callable`, or `tuple`; +- no implicit `Any` returns from typed functions; +- no subclassing an untyped third-party base without a narrow documented + suppression; +- no unused `type: ignore` comments. + +Use `Any` explicitly when an external library or genuinely heterogeneous event +requires it; an honest `Any` is preferable to a false specific type. Do not let +that become the default for domain objects—new workout, lap, step, result, and +event models should favor dataclasses, enums, protocols, or typed dictionaries. + +Preserve numeric boundaries deliberately: + +- Protocol/FIT-facing code may use `Decimal` or encoded integers. +- GUI/domain calculations normally use `float` and `int`. +- Conversion and rounding should happen at an explicit boundary, not + incidentally during formatting. +- Validate finite values and FIT field ranges before encoding. + +## Units and naming + +Physical quantities include their unit in names whenever ambiguity is possible: + +| Suffix | Meaning | Example | +|---|---|---| +| `_m` | metres | `distance_m` | +| `_s` | seconds | `duration_s` | +| `_ms` | milliseconds | `start_time_ms` | +| `_mps` | metres per second | `avg_speed_mps` | +| `_spm` | strokes per minute | `cadence_spm` | +| `_bpm` | beats per minute | `heart_rate_bpm` | +| `_n` | newtons | `pull_force_n` | +| `_w` | watts | `power_w` | + +Distinguish wall/epoch time from monotonic elapsed time in both names and +comments. Use `time.monotonic()` for deadlines, freshness, and durations; use +epoch time only for timestamps that must be persisted or exchanged. + +## Docstrings and comments + +- Use Google-style `Args:`, `Returns:`, and `Raises:` sections for public, + non-trivial functions and methods. +- Module docstrings are prose and need not use Google sections. +- Do not add ceremonial docstrings to trivial closures and event callbacks. +- Explain why a non-obvious invariant exists, not what a plainly readable line + does. +- Keep comments synchronized with behavior. In particular, do not claim a + collection is immutable when it contains mutable lists/dictionaries, or call + ergometer elapsed time “wall clock” unless it is actually driven by a + monotonic session clock. +- Link substantial behavioral rationale to `DECISIONS.md` rather than duplicating + a long history in source comments. + +## Domain design rules + +- Keep capture, persistence, finalization, presentation, and external upload as + distinct stages. +- One business rule should have one implementation. FIT, summary, chart legend, + and completion card must not independently redefine a finalized aggregate. + Live dashboard cards are presentation values and must consistently accumulate + the same instantaneous fields they display. +- Treat every canonical sample as + `[session_elapsed - sample_duration, session_elapsed]`, not as a timeless point. +- Make active-time and distance ownership explicit at pause and planned-step + boundaries. +- Do not infer activity from heart rate. +- Only generate derived FIT fields when their source and compatibility purpose + are explicit. The synthetic Hazewinkel positions must use finalized active + distance, and each run's GPS anchor must use its exact timer START and prior + cumulative distance. Do not generate unsupported calories, altitude, or + arbitrary GPS. +- Historical processing must use recorded metadata. +- Prefer immutable typed outputs from pure processing functions. +- Avoid process-global mutable configuration in reusable calculations. + +## GUI and concurrency + +- Tk and CustomTkinter widgets are touched only on the GUI thread. +- Workers communicate through scheduled callbacks/events. +- Long filesystem, BLE, FIT, OAuth, or HTTP work belongs outside the GUI thread. +- Every worker operation has a typed terminal outcome; completion is not + automatically success. +- Destructive actions are disabled while a worker owns the same artifact. +- Use monotonic deadlines and cancellable waits for worker operations. +- Catch widget-teardown exceptions only at the thread/UI boundary, not around + unrelated business logic. + +The existing GUI mixins are supported, but new behavior should avoid widening +their implicit shared state. Prefer explicit controller/service dependencies +when extracting or substantially changing a feature. + +## Filesystem and persistence + +- Use UTF-8 and explicit CSV delimiters/newline handling. +- Stream workout data incrementally; never keep the only copy in memory. +- Use sibling temporary files plus `os.replace()` for replaceable artifacts. +- Flush and fsync durable state at meaningful recovery boundaries. +- Never report success after swallowing a write error. +- Reserve output filenames without truncating an existing workout. +- When deleting a workout, define and remove its complete owned artifact set. +- Keep user secrets in the OS vault where available and restrict fallback file + permissions to the owner. + +## Input validation and errors + +- Validate external values at their point of use. +- Reject NaN/infinity and out-of-domain numeric values. +- Skip a malformed local record only when the remaining result is still + truthful; log the exact location and reason. +- Preserve a known fallback only when it is documented and semantically safe. +- Use typed exceptions/results at subsystem boundaries. +- Show actionable user-facing failures for recording, metadata, conversion, and + upload stages. +- Avoid `except Exception` around an entire operation unless it is a top-level + containment boundary that converts the error into a structured failure. + +## Logging + +Use the shared `Logger` wrapper: + +```python +self.logger = Logger.get_logger(name=__name__) +``` + +Plain modules may keep a module-level logger. Pure/testable modules can accept +an optional logger to avoid importing GUI or logging infrastructure. + +Logging levels: + +- `debug`: packet/detail information useful during diagnosis; +- `info`: lifecycle transitions and user-requested operations; +- `warning`: recoverable degradation, skipped local input, stale data; +- `error`: failed operations or loss of expected capability; +- `critical`: fatal startup/process failures. + +Do not log access tokens, refresh tokens, client secrets, authorization codes, +or full credential-bearing URLs. + +## Tests + +- Tests are ordinary pytest-discovered `test_*` functions with plain asserts. +- New test modules are automatically covered by the `tests.*` mypy override. +- Keep tests deterministic and independent of user home/config state by using + temporary paths and patched boundaries. +- Test invariants, not only example outputs. Important workout invariants include: + - planned lap timer time never exceeds elapsed time; + - the sum of attributed active time is conserved; + - inactive HR prefixes/tails do not extend activity; + - summary headline/splits match decoded FIT fields; + - failed FIT replacement preserves the old file; + - persistence failure cannot produce a success result; + - pending upload is distinct from confirmed activity creation. +- Use real `fit_tool` decoding in pipeline parity tests; stubs are appropriate + only for focused message-order/unit tests. + +## Documentation maintenance + +- User-visible behavior: update `README.md` and `FEATURES.md`. +- Runtime/data-flow changes: update `ARCHITECTURE.md`. +- New or reversed design choice: update `DECISIONS.md`. +- Unresolved issue or prioritization change: update `ROADMAP.md`. +- Development/tooling convention: update this guide and `AGENTS.md` if it + changes assistant behavior. +- Keep filenames and cross-links exact; canonical general docs use uppercase + names in `docs/`. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..5bd7b38 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,157 @@ +# Testing guide + +KayakFit uses pytest for behavioral tests, Ruff for lint/import validation, and +strict mypy for static type checking. All three are merge gates. + +## Required validation + +From the repository root: + +```bash +uv sync --locked +uv run ruff check . +uv run mypy . --strict +uv run pytest +git diff --check +``` + +The suite is safe to run in one pytest process and uses the locked runtime +dependencies rather than replacing packages in `sys.modules`. Do not rely on a +fixed test count; add or update coverage with each behavior change. + +## Choosing tests during development + +Run focused modules while iterating, then the full required validation before +handoff. + +| Area | Primary modules | +|---|---| +| BLE parsing | `tests/test_ble_parsing.py` | +| FIT timer-event ordering | `tests/test_fit_events.py` | +| CSV parsing and corruption recovery | `tests/test_read_csv.py` | +| Canonical capture timing and reconnects | `tests/test_recording_timeline.py` | +| Durable CSV writes and filename collisions | `tests/test_write_csv.py` | +| Activity detection and autopause | `tests/test_segmentation.py` | +| Planned program parsing/execution | `tests/test_program.py` | +| Planned-step laps and sample attribution | `tests/test_program_laps.py` | +| Shared metric aggregation | `tests/test_stats.py` | +| Summary timing, series, and HR zones | `tests/test_summary.py`, `tests/test_hr_zones.py` | +| Instantaneous live-card metric policy | `tests/test_metrics_format.py` | +| Encoded FIT/summary parity | `tests/test_pipeline.py` | +| Recovery marker behavior | `tests/test_recovery.py` | +| Worker outcomes, atomic state and strict configuration | `tests/test_reliability.py` | +| Strava/OAuth/upload outcomes | `tests/test_strava.py` | + +Example focused run: + +```bash +uv run pytest -q tests/test_program_laps.py +``` + +## Test design rules + +Tests should be deterministic, independent, and based on public behavior or a +deliberate subsystem boundary. + +- Use `tempfile` or pytest temporary paths for files. +- Patch home directories, clocks, credentials, devices, and network calls. +- Do not read or mutate the user's real `~/KayakFit` data. +- Prefer fixed epoch timestamps and explicit sample durations. +- Use plain asserts with failure values that explain the violated invariant. +- Use real `fit_tool` encoding/decoding for end-to-end FIT parity. +- Use lightweight stubs only for focused ordering/unit tests that do not claim + encoded-file correctness. +- Test error and recovery paths, not only the successful path. + +Important workout invariants include: + +- HR-only prefixes and tails do not extend activity. +- Initial/repeated zero-duration observations and reconnect gaps do not inflate active time. +- Poll command duration does not accumulate into the one-second polling period, + and a late command never produces catch-up bursts. +- Raw multi-second windows are capped by available canonical elapsed time, so + represented windows never overlap and uncovered time remains explicit. +- Every emitted FIT record belongs to an active ergometer window. +- Planned lap timer time does not exceed lap elapsed time. +- Planned step attribution conserves active time and active distance when the + performed timeline covers the session. +- FIT session/lap totals, averages, and maxima match `WorkoutSummary` after + decoding. +- Every session/lap average is at most its corresponding maximum, and the + session speed maximum is at least every lap speed maximum. +- FIT speed records, averages, peaks, and summary chart points use active + instantaneous sensor values. +- Session active time equals summed lap active time, and session active distance + equals summed lap active distance. +- Split formatting preserves finalized centimetre and millisecond values rather + than redistributing independently rounded units. +- Inactive distance changes do not inflate FIT/session/chart totals. +- Every distance-bearing active FIT record has a synthetic position derived + from its finalized cumulative distance; inactive distance cannot advance the + compatibility route. +- Every active run has a metric-free GPS anchor at its START, and its decoded + first-to-last GPS span equals its finalized timer time. +- Smoothing cannot change displayed averages or maxima. +- Delayed program samples preserve every crossed transition and exact boundary. +- A persistence failure cannot increment the saved-row count or report normal + completion. +- Failed FIT replacement preserves the last valid FIT. +- Pending Strava acceptance remains distinct from confirmed activity creation. + +## Adding a test module + +Name it `tests/test_.py`. Pytest discovers test functions named `test_*`. +Tests may follow the repository's lightweight annotation convention, but mypy +still checks them for real type errors. + +The `tests.*` mypy override automatically covers new test modules. It relaxes +annotation presence only; substantive type errors remain failures. + +## Manual desktop smoke test + +Automated tests do not exercise real Bluetooth permissions, physical devices, +Tk rendering, keychain backends, sleep prevention, or OS packaging. Before a +release, test on both supported platforms where possible. + +Suggested smoke flow: + +1. Launch from a clean user configuration. +2. Complete or revisit device setup and scan for peripherals. +3. Start a free workout and verify brief low-cadence gaps stay in one lap while + five continuous inactive seconds trigger the paused state. +4. Stop paddling while HR continues; confirm no HR tail extends the result. +5. Run a short planned workout containing warm-up, work, rest, and cooldown. +6. Verify live step cues, exact step changes, summary splits, and graphs. +7. Save FIT and compare its session/lap fields with the summary. +8. Exercise manual and automatic Strava paths with a test activity if + credentials are available. +9. Restart after an interrupted recording and verify recovery. +10. Confirm logs contain actionable errors and no credentials. + +For packaged builds, repeat startup, Bluetooth access, summary opening, FIT +creation, and credential access from the actual `.app` or `.exe`, not only from +source. + +## Restricted or offline environments + +If `uv` cannot write its default cache, point it at a writable temporary path: + +```bash +UV_CACHE_DIR=/tmp/kayakfit-uv-cache uv run --offline pytest -q tests/test_summary.py +``` + +Use `--offline` only when the environment is already synchronized. Distinguish +missing dependencies or platform tooling from product failures, and record any +check that could not be run. Do not modify project files or the checked-in lock +merely to work around a sandbox or unavailable network. + +## Continuous integration + +`.github/workflows/ci.yml` runs on pull requests into `main` and pushes to +`main`: + +- Linux: lock consistency, Ruff, strict mypy, and generated-requirements drift. +- macOS and Windows: the complete suite in one pytest process. + +A local pass is necessary but not sufficient for merge; platform CI must also +be green. diff --git a/export_worker.py b/export_worker.py new file mode 100644 index 0000000..83fc936 --- /dev/null +++ b/export_worker.py @@ -0,0 +1,179 @@ +"""Export worker: CSV-to-FIT conversion and Strava upload. + +Runs the post-workout export side of a session as a worker coordinated by the +main GUI, without blocking it. Given a workout file it converts a CSV to a +Garmin FIT (via :class:`app.export_fit.FitExporter`) and optionally uploads the +result to Strava (via :class:`app.strava_uploader.StravaUploader`): a CSV is +converted first and then uploaded if requested, while an existing FIT is +uploaded directly; with uploading off, a CSV is only converted and a FIT needs +no work. The companion :mod:`workout_worker` handles the live recording side. +""" + +from collections.abc import Callable +from pathlib import Path + +from app.config import parse_bool +from app.export_fit import FitExporter +from app.strava_uploader import StravaUploader +from app.worker_result import WorkerOutcome, WorkerResult +from gui.config_manager import ConfigManager + + +def run_export_worker( + file_path: str, + access_token: str, + refresh_token: str, + client_id: str, + client_secret: str, + strava_upload: str = "yes", + log_callback: Callable[[str], None] | None = None, + stop_event: object | None = None, +) -> WorkerResult: + """Run export worker for CSV to FIT conversion and optional Strava upload. + + Args: + file_path: Path to the workout file (CSV or FIT) + access_token: Strava access token + refresh_token: Strava refresh token + client_id: Strava client ID + client_secret: Strava client secret + strava_upload: Whether to upload to Strava ('yes'/'no') + log_callback: Optional callback function for logging + stop_event: Optional threading.Event to signal stop request + + Returns: + Dictionary with result status and updated tokens if refreshed + """ + + def log(msg: str) -> None: + return log_callback(msg + "\n") if log_callback else print(msg) + + file_obj = Path(file_path) + is_fit_file = file_obj.suffix.lower() == ".fit" + do_upload = parse_bool(strava_upload) + + log(f"Processing: {file_obj.name}") + log(f"File type: {'FIT' if is_fit_file else 'CSV'}") + log(f"Upload to Strava: {'Yes' if do_upload else 'No'}") + log("-" * 50) + + # Load configuration + config_mgr = ConfigManager() + try: + cfg = config_mgr.load_runtime_config(require_ergometer=False) + except Exception as exc: + return WorkerResult( + outcome="failed", + stage="configuration", + message=str(exc), + retryable=True, + ) + + # Convert CSV to FIT first (needed for both the save-only and upload paths). + if not is_fit_file: + log("Converting CSV to FIT...") + convert_result = FitExporter.convert_csv_to_fit(file_path) + if not convert_result.get("success"): + log(f"✗ Conversion failed: {convert_result.get('message')}") + return WorkerResult( + outcome="failed", + stage="fit_conversion", + message=str(convert_result.get("message") or "FIT conversion failed"), + csv_path=file_path, + retryable=True, + ) + fit_path = convert_result.get("file_path") + if not isinstance(fit_path, (str, Path)): + log("✗ Conversion returned no file path") + return WorkerResult( + outcome="failed", + stage="fit_conversion", + message="Conversion failed: no output file", + csv_path=file_path, + retryable=True, + ) + log(f"✓ Converted to FIT: {Path(fit_path).name}") + if not do_upload: + log("-" * 50) + return WorkerResult( + outcome="success", + stage="fit_conversion", + message="FIT file created successfully", + csv_path=str(file_obj), + fit_path=str(fit_path), + ) + file_path = str(fit_path) # Use converted FIT file for upload + elif not do_upload: + log("✓ No action required") + log("File is already in FIT format and upload not requested.") + return WorkerResult( + outcome="success", + stage="fit_conversion", + message="No action required", + fit_path=str(file_obj), + ) + + log("Uploading to Strava...") + uploader = StravaUploader( + access_token=access_token, + refresh_token=refresh_token, + client_id=client_id, + client_secret=client_secret, + config=cfg, + log_callback=log_callback, + expires_at=cfg.get("strava_expires_at", 0), + stop_event=stop_event, + ) + result = uploader.upload_file( + file_path=file_path, + name=None, # Let Strava auto-generate name + description=None, + ) + + # Persist refreshed tokens even when the upload itself failed: Strava + # rotates refresh tokens, so dropping the new one after a failed upload + # (e.g. offline) would strand the user with a dead credential and force + # a full reconnect. + new_access_token, new_refresh_token, new_expires_at = uploader.get_updated_tokens() + if new_access_token != access_token or new_refresh_token != refresh_token: + log("\nTokens were refreshed. Updating configuration...") + cfg["strava_access_token"] = new_access_token + cfg["strava_refresh_token"] = new_refresh_token + cfg["strava_expires_at"] = new_expires_at + config_mgr.save_config(cfg) + log("Configuration updated with new tokens.") + tokens_updated = True + else: + tokens_updated = False + + # Display result + if result["success"]: + log("✓ SUCCESS!") + log(result["message"]) + else: + log("✗ UPLOAD FAILED") + log(result["message"]) + + log("-" * 50) + log("Export process complete.") + + outcome: WorkerOutcome = "pending" if result.get("pending") else ( + "success" if result.get("success") else "failed" + ) + activity_id_raw = result.get("activity_id") + upload_id_raw = result.get("upload_id") + return WorkerResult( + outcome=outcome, + stage="strava_upload", + message=str(result.get("message") or "Upload finished"), + csv_path=str(file_obj) if not is_fit_file else None, + fit_path=str(file_path), + retryable=outcome in ("failed", "pending"), + activity_id=( + int(activity_id_raw) if activity_id_raw is not None else None + ), + upload_id=int(upload_id_raw) if upload_id_raw is not None else None, + tokens_updated=tokens_updated, + new_access_token=new_access_token if tokens_updated else None, + new_refresh_token=new_refresh_token if tokens_updated else None, + ) diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..a630e19 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,7 @@ +"""GUI package for KayakFit. + +Submodules are imported directly (e.g. ``from gui.main_gui import KayakFitGUI``) +rather than eagerly here, so that importing lightweight modules such as +``gui.config_manager`` from the headless workers does not pull in the GUI +toolkit (tkinter / customtkinter). +""" diff --git a/gui/_mixin_base.py b/gui/_mixin_base.py new file mode 100644 index 0000000..e6ff7cc --- /dev/null +++ b/gui/_mixin_base.py @@ -0,0 +1,111 @@ +"""Type-checking-only shared surface for the KayakFitGUI mixins. + +``main_gui.KayakFitGUI`` is assembled from four focused mixins +(``ProgramPanelMixin``, ``DeviceStatusMixin``, ``RecordingLifecycleMixin``, +``ExportUploadMixin``). Each mixin freely reads attributes and calls methods +that are actually defined on a *sibling* mixin or on ``KayakFitGUI`` itself, so +mypy -- which checks every mixin in isolation -- cannot see them and reports +spurious ``attr-defined`` / ``has-type`` errors. + +``GuiSharedState`` declares that shared surface in one place. Every member is +annotated ``Any`` on purpose: this class only exists to tell mypy the names +exist, not to pin their types (the real definitions -- widgets, StringVars, +methods, properties -- live in the mixins and in ``KayakFitGUI.__init__``). +Using ``Any`` keeps those real definitions from tripping override/redefinition +checks against this base. + +The mixins inherit this base **only under ``TYPE_CHECKING``** (see the +``if TYPE_CHECKING: _MixinBase = GuiSharedState else: _MixinBase = object`` +idiom in each mixin module), so there is no runtime base-class change and no +behavioural effect whatsoever. +""" + +from typing import Any + +from .controllers import ExportController, ProgramController, RecordingController + + +class GuiSharedState: + """Declared-only view of the composed ``KayakFitGUI`` surface (see module docstring).""" + + _advance_program: Any + _apply_program: Any + _apply_status: Any + _apply_upload: Any + _auto_export: Any + _clear_hr_zone: Any + _clear_target_indicator: Any + _free_label: Any + _hide_banner: Any + _hide_program_panel: Any + _hrm_battery: Any + _init_chips: Any + _last_data_ts: Any + _last_hr_ts: Any + _latest_data: Any + _live_speed: Any + _pace_distance: Any + _primary_fg: Any + _primary_hover: Any + _program_labels: Any + _program_warn_sig: Any + _programs: Any + _selected_program_id: Any + _refresh_hr_legend: Any + _reload_cfg: Any + _reset_device_chips: Any + _restore_tile_brightness: Any + _screen_awake: Any + _set_chip: Any + _set_export_buttons: Any + _set_hr_zone: Any + _show_banner: Any + _show_summary: Any + _sound: Any + _start_export: Any + _stats: Any + _strava_connected: Any + _tiles_dimmed: Any + _update_live_label: Any + _update_target_indicator: Any + export: ExportController + program: ProgramController + recording: RecordingController + _zones_cfg: Any + boat_weight_var: Any + boat_entry: Any + cfg: Any + config_mgr: Any + discard_btn: Any + hr_legend_labels: Any + hr_zone_var: Any + hrm_chip: Any + lap_label: Any + live_label: Any + log_message: Any + metric_sub_vars: Any + metric_vars: Any + open_config: Any + person_weight_var: Any + person_entry: Any + primary_btn: Any + program_menu: Any + program_advance_btn: Any + program_panel: Any + program_preview_var: Any + program_var: Any + recording_pill: Any + root: Any + input_error_var: Any + savefit_btn: Any + strava_btn: Any + summary_card: Any + summary_label: Any + tile_defaults: Any + tile_frames: Any + tile_value_colors: Any + tile_value_labels: Any + upload_btn: Any + view_summary_btn: Any + worker_mgr: Any + workouts_btn: Any diff --git a/gui/config_manager.py b/gui/config_manager.py new file mode 100644 index 0000000..ae4c8b0 --- /dev/null +++ b/gui/config_manager.py @@ -0,0 +1,362 @@ +"""Single source of truth for KayakFit configuration (YAML). + +Manages Strava credentials, device settings, and user preferences for both +the GUI and the headless data-collection / export workers: one shared set of +defaults, YAML storage in the user's home directory, invalid-file quarantine +for the GUI, and shared strict validation for GUI and workers. Secrets are overlaid from +the OS credential vault (``app.secret_store``) rather than kept in the +plaintext file. Deliberately has no GUI dependencies so headless workers can +import it. +""" + +import contextlib +import os +import re +import stat +from datetime import datetime +from pathlib import Path +from typing import Any, ClassVar + +import yaml + +from app import secret_store +from app.power import DEFAULT_PULL_LENGTH_M + +# Address fields that may accidentally carry a "Name (address)" dropdown label. +_ADDRESS_KEYS = ("ergometer_mac", "hrm_mac") + + +def _extract_addr(value: str) -> str: + """Return the bare BLE address from a possible 'Name (ADDRESS)' label. + + Device dropdowns display 'Name (address)' and CTkOptionMenu writes that + label back into its bound variable, so it can leak into stored values. A + real MAC/UUID has no parentheses, so this is safe for clean values too. + """ + value = (value or "").strip() + if value in ("", "—"): + return "" + match = re.search(r"\(([^()]+)\)\s*$", value) + return match.group(1).strip() if match else value + + +class ConfigError(Exception): + """Raised when configuration cannot be read or fails validation.""" + + +class ConfigManager: + """Manages configuration file persistence (single source of truth).""" + + DISPLAY_MAP: ClassVar[dict[int, str]] = { + 0: "Elapsed time", + 2: "Total distance (*)", + 3: "Avg. speed (*)", + 4: "Inst. speed", + 5: "Avg. stroke rate", + 6: "Inst. stroke rate", + 7: "Avg. 200m pace (sec)", + 8: "Inst. 200m pace (sec)", + 9: "Avg. 500m pace (sec)", + 10: "Inst. 500m pace (sec)", + 11: "Avg. 1000m pace (sec)", + 12: "Inst. 1000m pace (sec)", + 13: "Avg. force", + 14: "Inst. force", + 15: "Total Expended Energy (*) (kCal)", + 17: "Inst. power (W)", + 18: "Avg. power (W)", + } + + # Canonical defaults, shared by the GUI and the workers. Values are stored + # in their on-disk (string/int/list) form; runtime validation happens in + # load_runtime_config(). + DEFAULTS: ClassVar[dict[str, Any]] = { + "ergometer_name": "", + "ergometer_mac": "", + # Display label of the chosen strap. HRMs are discovered by the BLE + # Heart Rate Service (not by name), so this is informational only. + "hrm_name": "", + "hrm_mac": "", + # "no" skips the HRM entirely at workout start: no connection attempt + # and no "continue without HRM?" prompt (for users without a strap). + "hrm_enabled": "yes", + "person_weight_default": 75, + "boat_weight_default": 12, + # Effective handle pull length (m) used for power estimation + # (power = instantaneous force x pull_length x strokes/s). See + # docs/POWER_MODEL.md to refit it. + "pull_length_m": DEFAULT_PULL_LENGTH_M, + "max_hr": 185, + # HR zones. "auto" derives the five zone bounds from max_hr; "manual" + # uses hr_zones (five ascending bpm lower bounds, Z1..Z5). + "hr_zone_mode": "auto", + "hr_zones": [], + "pace_distance": 500, + "appearance_mode": "system", + "activity_log_visible": True, + "display_config_numbers": [0, 2, 4, 14, 17], + "log_interval": 5, + "log_level": "info", + # BLE connection stability settings. + "ble_connect_timeout": 15, + "ble_connect_retries": 3, + "ble_auto_reconnect": "yes", + "ble_reconnect_delay": 5, + # Startup connection attempts for the HRM. Kept low so a missing strap + # surfaces the "continue without HRM?" prompt quickly; mid-session + # auto-reconnect is unaffected. + "hrm_connect_retries": 1, + "hrm_stale_timeout": 10, + "data_stall_timeout": 8, + "auto_upload": "no", + # End (and save) a workout after this many minutes without forward + # movement, so a forgotten session doesn't become a multi-hour activity. + # 0 disables it. + "inactivity_autostop_minutes": 30, + # Set once the first-run setup wizard has been shown. + "wizard_seen": False, + # Play a sound when a program phase (work/rest) changes. + "sound_cues": "yes", + # Seconds a program target must stay out of (or back in) range before the + # on-screen badge changes color — smooths normal metric fluctuation. + "target_grace_seconds": 3, + "output_dir": "workouts", + "strava_client_id": "", + "strava_client_secret": "", + "strava_access_token": "", + "strava_refresh_token": "", + "strava_expires_at": 0, + } + + def __init__(self) -> None: + """Initialize config manager and ensure the config directory exists.""" + self.config_folder: Path = Path.home() / "KayakFit" + self.config_folder.mkdir(parents=True, exist_ok=True) + self.config_file: Path = self.config_folder / "config.yml" + self.config_warning: str | None = None + + def _read_raw(self) -> dict[str, Any]: + """Read the raw YAML config from disk. + + Returns: + Parsed dictionary (empty if the file does not exist). + + Raises: + ConfigError: If the file exists but cannot be read or parsed. + """ + if not self.config_file.exists(): + return {} + try: + with open(self.config_file, encoding="utf-8") as f: + data = yaml.safe_load(stream=f) or {} + if not isinstance(data, dict): + raise ConfigError("Configuration root must be a mapping") + return data + except yaml.YAMLError as e: + raise ConfigError(f"Could not parse config file {self.config_file}: {e}") from e + except OSError as e: + raise ConfigError(f"Could not read config file {self.config_file}: {e}") from e + + def _merge_with_defaults(self, data: dict[str, Any]) -> dict[str, Any]: + """Merge file data onto defaults, keeping only recognized keys.""" + allowed = set(self.DEFAULTS) + unknown = sorted(set(data) - allowed) + if unknown: + raise ConfigError(f"Unknown configuration keys: {unknown}") + cfg: dict[str, Any] = dict(self.DEFAULTS) + cfg.update({k: v for k, v in data.items() if k in allowed}) + self._apply_credential_store(cfg) + # Self-heal device addresses that may have been stored as 'Name (addr)'. + for key in _ADDRESS_KEYS: + cfg[key] = _extract_addr(str(cfg.get(key, ""))) + return self.validate_config(cfg) + + def validate_config(self, cfg: dict[str, Any]) -> dict[str, Any]: + """Return one canonical validated configuration.""" + validated = dict(cfg) + ranges: dict[str, tuple[type[int] | type[float], float, float]] = { + "person_weight_default": (int, 20, 300), + "boat_weight_default": (int, 0, 100), + "pull_length_m": (float, 0.3, 1.2), + "max_hr": (int, 100, 240), + "pace_distance": (int, 200, 1000), + "log_interval": (int, 1, 3600), + "ble_connect_timeout": (float, 1, 120), + "ble_connect_retries": (int, 1, 20), + "ble_reconnect_delay": (float, 0, 120), + "hrm_connect_retries": (int, 1, 20), + "hrm_stale_timeout": (float, 0, 300), + "data_stall_timeout": (float, 1, 300), + "inactivity_autostop_minutes": (float, 0, 1440), + "target_grace_seconds": (int, 1, 60), + "strava_expires_at": (int, 0, 4_102_444_800), + } + for key, (kind, minimum, maximum) in ranges.items(): + value = validated.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ConfigError(f"{key} must be numeric") + if kind is int and not isinstance(value, int): + raise ConfigError(f"{key} must be a whole number") + if not minimum <= float(value) <= maximum: + raise ConfigError(f"{key} must be between {minimum:g} and {maximum:g}") + validated[key] = kind(value) + if validated["pace_distance"] not in (200, 500, 1000): + raise ConfigError("pace_distance must be 200, 500, or 1000") + if str(validated.get("appearance_mode", "")).lower() not in ( + "system", "light", "dark" + ): + raise ConfigError("appearance_mode must be system, light, or dark") + if not isinstance(validated.get("activity_log_visible"), bool): + raise ConfigError("activity_log_visible must be true or false") + if str(validated.get("log_level", "")).lower() not in ( + "debug", "info", "warning", "error", "critical" + ): + raise ConfigError("log_level is invalid") + if validated.get("hr_zone_mode") not in ("auto", "manual"): + raise ConfigError("hr_zone_mode must be 'auto' or 'manual'") + zones = validated.get("hr_zones") + if not isinstance(zones, list) or any( + isinstance(value, bool) or not isinstance(value, int) for value in zones + ): + raise ConfigError("hr_zones must contain whole numbers") + if validated["hr_zone_mode"] == "manual" and ( + len(zones) != 5 or zones != sorted(set(zones)) + ): + raise ConfigError("manual hr_zones must contain five ascending values") + display = validated.get("display_config_numbers") + if ( + not isinstance(display, list) + or not 1 <= len(display) <= 5 + or any( + isinstance(value, bool) or not isinstance(value, int) + for value in display + ) + or len(display) != len(set(display)) + ): + raise ConfigError("display_config_numbers must contain unique integer slots") + if any(value not in self.DISPLAY_MAP for value in display): + raise ConfigError("display_config_numbers contains an unsupported slot") + for key in ("hrm_enabled", "ble_auto_reconnect", "auto_upload", "sound_cues"): + if validated.get(key) not in ("yes", "no"): + raise ConfigError(f"{key} must be 'yes' or 'no'") + if not isinstance(validated.get("wizard_seen"), bool): + raise ConfigError("wizard_seen must be true or false") + for key in ( + "ergometer_name", "ergometer_mac", "hrm_name", "hrm_mac", + "output_dir", "strava_client_id", "strava_client_secret", + "strava_access_token", "strava_refresh_token", + ): + value = validated.get(key) + if not isinstance(value, str): + raise ConfigError(f"{key} must be text") + # PyObjC and other platform libraries can expose string subclasses + # that pass isinstance(value, str) but PyYAML refuses to represent. + # Canonical configuration contains plain built-in strings only. + validated[key] = str(value) + return validated + + def _quarantine_invalid_config(self, message: str) -> None: + """Preserve invalid input and expose a warning for the GUI.""" + if not self.config_file.exists(): + return + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + invalid = self.config_file.with_name(f"config.invalid-{stamp}.yml") + try: + os.replace(self.config_file, invalid) + location = invalid.name + except OSError as exc: + location = self.config_file.name + message = f"{message}; could not rename it: {exc}" + self.config_warning = f"Invalid configuration remains at {location}: {message}" + + @staticmethod + def _apply_credential_store(cfg: dict[str, Any]) -> None: + """Overlay complete Strava credentials from the OS vault (vault wins).""" + for key, value in secret_store.load_credentials().items(): + if value: + cfg[key] = value + + def _harden_permissions(self) -> None: + """Restrict the config file to the owner (no-op effect on some platforms).""" + with contextlib.suppress(OSError): + os.chmod(self.config_file, stat.S_IRUSR | stat.S_IWUSR) # 0o600 + + def load_config(self) -> dict[str, Any]: + """Load GUI configuration, quarantining invalid explicit input. + + Returns: + Configuration dictionary with all default keys present. + """ + try: + return self._merge_with_defaults(self._read_raw()) + except ConfigError as exc: + self._quarantine_invalid_config(str(exc)) + return self.validate_config(dict(self.DEFAULTS)) + + def load_runtime_config(self, require_ergometer: bool = True) -> dict[str, Any]: + """Load and validate configuration for the data-collection worker. + + Returns: + Validated configuration dictionary. + + Raises: + ConfigError: If the file is invalid or required fields are missing. + """ + cfg = self._merge_with_defaults(self._read_raw()) + + ergometer_mac = str(cfg.get("ergometer_mac", "")).strip() + if require_ergometer and not ergometer_mac: + raise ConfigError( + "No ergometer configured. Open Settings and scan/select your ergometer." + ) + + return cfg + + def save_config(self, cfg: dict[str, Any]) -> None: + """Save configuration, keeping secrets out of the plaintext file when possible. + + Sensitive Strava credentials are written to the OS credential vault if one + is available; the on-disk YAML then stores blanks for those keys. When no + vault is available the secrets remain in the file, which is restricted to + the owner. Either way the file is permission-hardened. + + Args: + cfg: Configuration dictionary to save. + """ + to_disk = self.validate_config(cfg) + + # Prefer the OS vault; blank the secrets on disk only if it accepted them. + if secret_store.store_credentials(cfg): + for key in secret_store.SENSITIVE_KEYS: + if key in to_disk: + to_disk[key] = "" + + # Write to a temp file in the same directory, then atomically replace the + # real config. A crash or full disk mid-write can only ever damage the + # throwaway temp file, never truncate the live config into unparseable + # YAML. Create the temp owner-only (0600) from the start so secrets are + # never briefly world-readable (os.open applies the mode at creation). + tmp_path = self.config_file.with_name(self.config_file.name + ".tmp") + fd = os.open( + tmp_path, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + stat.S_IRUSR | stat.S_IWUSR, + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + yaml.safe_dump(data=to_disk, stream=f, sort_keys=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.config_file) + except yaml.YAMLError as exc: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + raise ConfigError(f"Could not serialize configuration: {exc}") from exc + except OSError: + # Best-effort cleanup so a failed save doesn't leave a stale temp. + with contextlib.suppress(OSError): + os.unlink(tmp_path) + raise + + # Keep the final file owner-only on platforms that support POSIX modes. + self._harden_permissions() diff --git a/gui/config_window.py b/gui/config_window.py new file mode 100644 index 0000000..60e8970 --- /dev/null +++ b/gui/config_window.py @@ -0,0 +1,711 @@ +"""Settings dialog for KayakFit, built with customtkinter. + +Covers device selection (with BLE scanning), weight defaults, ergometer +display layout, logging/processing options, and Strava connection. +""" + +import queue +import threading +import tkinter as tk +from collections.abc import Callable +from dataclasses import dataclass +from tkinter import messagebox +from typing import Any + +import customtkinter as ctk + +from app import secret_store +from app.stats import zone_lower_bounds +from app.strava_auth import StravaAuth +from gui.config_manager import ConfigError, _extract_addr +from gui.device_scanner import scan_devices, test_connection +from gui.window_utils import center_over_parent + +LogFn = Callable[[str], None] + +# Muted hint text, matching the main dashboard's subtext color (light, dark). +_MUTED = ("#5f5f5f", "#9a9a9a") + + +@dataclass(frozen=True) +class SettingsFields: + """Widget-variable boundary consumed by settings validation and saving.""" + + erg_name: Any + erg_mac: Any + hrm_name: Any + hrm_mac: Any + hrm_enabled: Any + person_weight: Any + boat_weight: Any + max_hr: Any + hr_zone_mode: Any + hr_zones: list[Any] + display: list[Any] + log_interval: Any + log_level: Any + auto_upload: Any + sound_cues: Any + target_grace: Any + client_id: Any + client_secret: Any +# Destructive-action red, matching the main window's Stop button. +_DANGER = "#c0392b" +_DANGER_HOVER = "#a02c20" + + +def open_config_window( + root: ctk.CTk, + cfg: dict[str, Any], + config_mgr: Any, + on_save_callback: Callable[[], None] | None, + log: LogFn, +) -> None: + """Open the configuration window. + + Args: + root: Parent window. + cfg: Current configuration dictionary. + config_mgr: ConfigManager instance. + on_save_callback: Called after the config is saved/closed. + log: Callback to append a line to the main log. + """ + win = ctk.CTkToplevel(master=root) + win.title("Settings") + # Wide enough that every hint label (e.g. the "Logging & processing" + # descriptions) is fully readable without resizing; centered over the + # main window and clamped to the screen height. + center_over_parent(win, root, 780, 860) + win.minsize(700, 480) + win.transient(root) + win.after(200, win.grab_set) # grab after the window is mapped + + scan_stop_event = threading.Event() + callback_needed = {"value": False} + + def on_close() -> None: + scan_stop_event.set() + if callback_needed["value"] and on_save_callback: + on_save_callback() + win.destroy() + + win.protocol("WM_DELETE_WINDOW", on_close) + + container = ctk.CTkScrollableFrame(win) + container.pack(fill="both", expand=True, padx=14, pady=14) + + # ---- variables ------------------------------------------------------ + def _device_label(name: Any, mac: Any) -> str: + """Build a 'Name (MAC)' dropdown label; fall back to MAC or a dash.""" + name = str(name or "").strip() + mac = str(mac or "").strip() + if name and mac: + return f"{name} ({mac})" + return mac or "—" + + erg_name_var = tk.StringVar(value=cfg.get("ergometer_name", "")) + erg_mac_var = tk.StringVar( + value=_device_label(cfg.get("ergometer_name"), cfg.get("ergometer_mac")) + ) + # HRM name is no longer user-editable (HRMs are found by BLE service, not by + # name), but we keep the discovered name to show "Name (MAC)" in the dropdown. + hrm_name_var = tk.StringVar(value=cfg.get("hrm_name", "")) + hrm_mac_var = tk.StringVar( + value=_device_label(cfg.get("hrm_name"), cfg.get("hrm_mac")) + ) + hrm_enabled_var = tk.StringVar(value=str(cfg.get("hrm_enabled", "yes"))) + person_weight_var = tk.StringVar(value=str(cfg.get("person_weight_default", 75))) + boat_weight_var = tk.StringVar(value=str(cfg.get("boat_weight_default", 12))) + max_hr_var = tk.StringVar(value=str(cfg.get("max_hr", 185))) + hr_zone_mode_var = tk.StringVar(value=str(cfg.get("hr_zone_mode", "auto"))) + # Five zone lower-bound entries (Z1..Z5). Prefill from a manual config if + # present, otherwise from the auto bounds derived from max HR. + _existing_zones = cfg.get("hr_zones") or [] + if not (isinstance(_existing_zones, list) and len(_existing_zones) == 5): + _existing_zones = zone_lower_bounds(max_hr=cfg.get("max_hr", 185)) + hr_zone_vars = [tk.StringVar(value=str(int(_existing_zones[i]))) for i in range(5)] + client_id_var = tk.StringVar(value=cfg.get("strava_client_id", "")) + client_secret_var = tk.StringVar(value=cfg.get("strava_client_secret", "")) + + # Display configuration: normalize to 5 valid, unique entries. + numbers = [n for n in cfg.get("display_config_numbers", []) if n in config_mgr.DISPLAY_MAP] + for key in sorted(config_mgr.DISPLAY_MAP): + if len(numbers) >= 5: + break + if key not in numbers: + numbers.append(key) + numbers = numbers[:5] + display_vars = [tk.StringVar(value=config_mgr.DISPLAY_MAP[numbers[i]]) for i in range(5)] + + log_interval_var = tk.StringVar(value=str(cfg.get("log_interval", 5))) + log_level_var = tk.StringVar(value=cfg.get("log_level", "info")) + auto_upload_var = tk.StringVar(value=str(cfg.get("auto_upload", "no"))) + sound_cues_var = tk.StringVar(value=str(cfg.get("sound_cues", "yes"))) + target_grace_var = tk.StringVar(value=str(cfg.get("target_grace_seconds", 3))) + + # ---- Devices -------------------------------------------------------- + devices = _section(container, "Devices") + + ctk.CTkLabel(devices, text="Ergometer name").grid(row=0, column=0, sticky="w", padx=8, pady=6) + ctk.CTkEntry(devices, textvariable=erg_name_var, width=160).grid( + row=0, column=1, sticky="ew", padx=8, pady=6 + ) + erg_menu = ctk.CTkOptionMenu(devices, values=[erg_mac_var.get()], variable=erg_mac_var) + erg_menu.grid(row=1, column=0, columnspan=2, sticky="ew", padx=8, pady=(0, 6)) + + # Off = the workout never tries to connect an HRM and never asks + # "continue without HRM?" — for users without a strap. + hrm_switch = ctk.CTkSwitch( + devices, text="Use heart-rate monitor", + variable=hrm_enabled_var, onvalue="yes", offvalue="no", + ) + hrm_switch.grid(row=2, column=0, columnspan=2, sticky="w", padx=8, pady=(10, 2)) + + # HRMs are discovered by their BLE Heart Rate Service, so there is no name + # field to fill in — just scan and pick the strap from this dropdown, which + # lists each match as "Name (MAC)". + ctk.CTkLabel(devices, text="Heart-rate monitor").grid( + row=3, column=0, sticky="w", padx=8, pady=6 + ) + hrm_menu = ctk.CTkOptionMenu(devices, values=[hrm_mac_var.get()], variable=hrm_mac_var) + hrm_menu.grid(row=3, column=1, sticky="ew", padx=8, pady=6) + + erg_map: dict[str, tuple[str, str]] = {} + hrm_map: dict[str, tuple[str, str]] = {} + + def handle_scan_result(result: dict[str, Any]) -> None: + if result.get("cancelled"): + log("Scan cancelled.\n") + return + if result.get("error"): + log(f"Scan error: {result['error']}\n") + log("Make sure Bluetooth is enabled and devices are nearby.\n") + return + + erg_devices: list[tuple[str, str]] = result.get("erg_devices", []) + hrm_devices: list[tuple[str, str]] = result.get("hrm_devices", []) + + erg_map.clear() + hrm_map.clear() + + if erg_devices: + for name, addr in erg_devices: + erg_map[f"{name} ({addr})"] = (name, addr) + erg_menu.configure(values=list(erg_map)) + first = erg_devices[0] + erg_name_var.set(first[0]) + erg_menu.set(f"{first[0]} ({first[1]})") + log(f"Found {len(erg_devices)} ergometer device(s).\n") + else: + log("No ergometer devices found.\n") + + if hrm_devices: + for name, addr in hrm_devices: + hrm_map[f"{name} ({addr})"] = (name, addr) + hrm_menu.configure(values=list(hrm_map)) + first = hrm_devices[0] + hrm_name_var.set(first[0]) + hrm_menu.set(f"{first[0]} ({first[1]})") + log(f"Found {len(hrm_devices)} HRM device(s).\n") + else: + log("No HRM devices found.\n") + + log(f"Scan complete. {result.get('total', 0)} total devices seen.\n") + + def on_erg_selected(choice: str) -> None: + # The menu already holds the "Name (MAC)" label; keep it for display and + # just sync the name field. _extract_addr pulls the MAC out on save. + if choice in erg_map: + name, _ = erg_map[choice] + erg_name_var.set(name) + + def on_hrm_selected(choice: str) -> None: + # Keep the "Name (MAC)" label shown; record the name for persistence. + if choice in hrm_map: + name, _ = hrm_map[choice] + hrm_name_var.set(name) + + erg_menu.configure(command=on_erg_selected) + hrm_menu.configure(command=on_hrm_selected) + + def start_scan() -> None: + scan_stop_event.clear() + # A blank ergometer name would match nothing; fall back to the device + # family name ("Bull"), mirroring the first-run wizard. + scan_devices( + erg_pattern=erg_name_var.get().strip() or "bull", + on_log=log, + on_result=handle_scan_result, + schedule=lambda fn: win.after(0, fn), + stop_event=scan_stop_event, + ) + + ctk.CTkButton(devices, text="Scan for devices", command=start_scan).grid( + row=5, column=0, columnspan=2, sticky="ew", padx=8, pady=(6, 4) + ) + + def make_test_handler( + btn: ctk.CTkButton, btn_text: str, label: str + ) -> Callable[[dict[str, Any]], None]: + """Build a result handler that re-enables ``btn`` and reports the outcome.""" + + def handler(res: dict[str, Any]) -> None: + btn.configure(state="normal", text=btn_text) + if res.get("ok"): + log(f"✓ {label} connection OK.\n") + messagebox.showinfo("Connection test", f"{label} connected successfully.") + else: + log(f"✗ {label} connection failed: {res.get('error', 'could not connect')}\n") + messagebox.showerror( + "Connection test", + f"Could not connect to the {label.lower()}:\n" + f"{res.get('error', 'unknown error')}\n\n" + f"Make sure the {label.lower()} is on, nearby, and not paired elsewhere.", + ) + + return handler + + def run_test( + addr_var: tk.StringVar, btn: ctk.CTkButton, btn_text: str, label: str + ) -> None: + addr = _extract_addr(addr_var.get()) + if not addr: + messagebox.showwarning("Connection test", f"Select an {label.lower()} first.") + return + btn.configure(state="disabled", text="Testing…") + test_connection( + address=addr, + on_log=log, + on_result=make_test_handler(btn, btn_text, label), + schedule=lambda fn: win.after(0, fn), + ) + + # Both device tests sit side by side on one row as equal halves. + test_row = ctk.CTkFrame(devices, fg_color="transparent") + test_row.grid(row=6, column=0, columnspan=2, sticky="ew", padx=8, pady=(0, 4)) + test_row.grid_columnconfigure(0, weight=1, uniform="test") + test_row.grid_columnconfigure(1, weight=1, uniform="test") + + # Forward-declared so the command lambda (which captures the button to + # disable it during the test) has a known type despite referencing the + # button inside its own initializer. + test_btn: ctk.CTkButton + test_btn = ctk.CTkButton( + test_row, text="Test ergometer", + command=lambda: run_test(erg_mac_var, test_btn, "Test ergometer", "Ergometer"), + ) + test_btn.grid(row=0, column=0, sticky="ew", padx=(0, 4)) + + test_hrm_btn: ctk.CTkButton + test_hrm_btn = ctk.CTkButton( + test_row, text="Test HRM", + command=lambda: run_test(hrm_mac_var, test_hrm_btn, "Test HRM", "HRM"), + ) + test_hrm_btn.grid(row=0, column=1, sticky="ew", padx=(4, 0)) + devices.grid_columnconfigure(1, weight=1) + + def apply_hrm_enabled() -> None: + """Gray out the HRM fields while the HRM is disabled.""" + hrm_state = "normal" if hrm_enabled_var.get() == "yes" else "disabled" + for widget in (hrm_menu, test_hrm_btn): + widget.configure(state=hrm_state) + + hrm_switch.configure(command=apply_hrm_enabled) + apply_hrm_enabled() + + # ---- Weights -------------------------------------------------------- + weights = _section(container, "Athlete & equipment") + ctk.CTkLabel(weights, text="Boat (kg)").grid(row=0, column=0, sticky="w", padx=8, pady=6) + ctk.CTkEntry(weights, textvariable=boat_weight_var, width=80).grid( + row=0, column=1, sticky="w", padx=8 + ) + ctk.CTkLabel(weights, text="Person (kg)").grid(row=0, column=2, sticky="w", padx=(20, 8)) + ctk.CTkEntry(weights, textvariable=person_weight_var, width=80).grid( + row=0, column=3, sticky="w", padx=8 + ) + + # ---- Heart-rate zones ---------------------------------------------- + hrz = _section(container, "Heart-rate zones") + ctk.CTkLabel(hrz, text="Max HR (bpm)").grid(row=0, column=0, sticky="w", padx=8, pady=6) + ctk.CTkEntry(hrz, textvariable=max_hr_var, width=80).grid(row=0, column=1, sticky="w", padx=8) + ctk.CTkLabel( + hrz, text="Used to auto-derive heart-rate zones", text_color=_MUTED + ).grid(row=0, column=2, columnspan=2, sticky="w", padx=(20, 8)) + ctk.CTkLabel(hrz, text="Mode").grid(row=1, column=0, sticky="w", padx=8, pady=6) + zone_entries: list[ctk.CTkEntry] = [] + + def _sync_zone_entry_state(*_a: Any) -> None: + manual = hr_zone_mode_var.get() == "manual" + for entry in zone_entries: + entry.configure(state="normal" if manual else "disabled") + + ctk.CTkOptionMenu( + hrz, values=["auto", "manual"], variable=hr_zone_mode_var, width=110, + command=lambda *_: _sync_zone_entry_state(), + ).grid(row=1, column=1, sticky="w", padx=8) + ctk.CTkLabel( + hrz, text="Auto = from Max HR · Manual = bpm lower bounds below", + text_color=_MUTED, + ).grid(row=1, column=2, columnspan=2, sticky="w", padx=(20, 8)) + + zone_names = ["Z1 Recovery", "Z2 Easy", "Z3 Aerobic", "Z4 Threshold", "Z5 Max"] + for i, zname in enumerate(zone_names): + # One zone per row so all five entries are always visible regardless of + # window width. + ctk.CTkLabel(hrz, text=f"{zname} ≥").grid( + row=2 + i, column=0, sticky="w", padx=8, pady=4 + ) + entry = ctk.CTkEntry(hrz, textvariable=hr_zone_vars[i], width=80) + entry.grid(row=2 + i, column=1, sticky="w", padx=8, pady=4) + ctk.CTkLabel(hrz, text="bpm", text_color=_MUTED).grid( + row=2 + i, column=2, sticky="w", padx=(0, 8) + ) + zone_entries.append(entry) + _sync_zone_entry_state() + + # ---- Display configuration ----------------------------------------- + display = _section(container, "Display layout") + display_options = [name for _, name in sorted(config_mgr.DISPLAY_MAP.items())] + display_menus: list[ctk.CTkOptionMenu] = [] + + def refresh_display_options(*_args: Any) -> None: + """Hide already-chosen metrics from the other slot dropdowns.""" + chosen = [v.get() for v in display_vars] + for idx, menu in enumerate(display_menus): + allowed = [ + opt for opt in display_options + if opt == display_vars[idx].get() or opt not in chosen + ] + menu.configure(values=allowed) + + for i in range(5): + ctk.CTkLabel(display, text=f"Slot {i + 1}").grid( + row=i, column=0, sticky="w", padx=8, pady=4 + ) + menu = ctk.CTkOptionMenu( + display, values=display_options, variable=display_vars[i], width=260, + command=lambda *_: refresh_display_options(), + ) + menu.grid(row=i, column=1, sticky="ew", padx=8, pady=4) + display_menus.append(menu) + refresh_display_options() + display.grid_columnconfigure(1, weight=1) + + # ---- Settings ------------------------------------------------------- + settings = _section(container, "Workout & processing") + ctk.CTkLabel(settings, text="Log interval (s)").grid( + row=0, column=0, sticky="w", padx=8, pady=6 + ) + ctk.CTkOptionMenu(settings, values=["1", "5", "10"], variable=log_interval_var, width=90).grid( + row=0, column=1, sticky="w", padx=8 + ) + ctk.CTkLabel(settings, text="Log level").grid(row=0, column=2, sticky="w", padx=(20, 8)) + ctk.CTkOptionMenu( + settings, values=["debug", "info", "warning", "error"], variable=log_level_var, width=110 + ).grid(row=0, column=3, sticky="w", padx=8) + + ctk.CTkLabel(settings, text="Auto-upload to Strava").grid( + row=1, column=0, sticky="w", padx=8, pady=6 + ) + ctk.CTkSwitch( + settings, text="", variable=auto_upload_var, onvalue="yes", offvalue="no", + ).grid(row=1, column=1, sticky="w", padx=8) + ctk.CTkLabel( + settings, text="Upload automatically when a workout ends", text_color=_MUTED + ).grid(row=1, column=2, columnspan=2, sticky="w", padx=(20, 8)) + + ctk.CTkLabel(settings, text="Sound cues").grid(row=2, column=0, sticky="w", padx=8, pady=6) + ctk.CTkSwitch( + settings, text="", variable=sound_cues_var, onvalue="yes", offvalue="no", + ).grid(row=2, column=1, sticky="w", padx=8) + ctk.CTkLabel( + settings, text="Play a sound on program phase changes", text_color=_MUTED + ).grid(row=2, column=2, columnspan=2, sticky="w", padx=(20, 8)) + + ctk.CTkLabel(settings, text="Target grace (s)").grid( + row=3, column=0, sticky="w", padx=8, pady=6 + ) + ctk.CTkOptionMenu( + settings, values=["1", "2", "3", "5", "8"], variable=target_grace_var, width=90 + ).grid(row=3, column=1, sticky="w", padx=8) + ctk.CTkLabel( + settings, text="Seconds out of range before the target badge changes", + text_color=_MUTED, + ).grid(row=3, column=2, columnspan=2, sticky="w", padx=(20, 8)) + + # ---- Strava --------------------------------------------------------- + strava = _section(container, "Strava") + + def rebuild_strava() -> None: + for child in strava.winfo_children(): + child.destroy() + if secret_store.has_complete_credentials(cfg): + _build_strava_connected(strava, cfg, config_mgr, rebuild_strava, callback_needed) + else: + _build_strava_connect( + strava, client_id_var, client_secret_var, win, cfg, + config_mgr, rebuild_strava, callback_needed, + ) + + rebuild_strava() + + fields = SettingsFields( + erg_name=erg_name_var, erg_mac=erg_mac_var, + hrm_name=hrm_name_var, hrm_mac=hrm_mac_var, + hrm_enabled=hrm_enabled_var, + person_weight=person_weight_var, boat_weight=boat_weight_var, + max_hr=max_hr_var, hr_zone_mode=hr_zone_mode_var, hr_zones=hr_zone_vars, + display=display_vars, log_interval=log_interval_var, + log_level=log_level_var, + auto_upload=auto_upload_var, sound_cues=sound_cues_var, + target_grace=target_grace_var, client_id=client_id_var, + client_secret=client_secret_var, + ) + + # ---- Save ----------------------------------------------------------- + ctk.CTkButton( + container, + text="Save settings", + height=40, + command=lambda: _submit( + cfg, config_mgr, win, fields, on_save_callback, + ), + ).pack(fill="x", padx=4, pady=(8, 4)) + + +def _section(parent: Any, title: str) -> ctk.CTkFrame: + """Create a titled section frame and return its body frame.""" + ctk.CTkLabel( + parent, text=title, font=ctk.CTkFont(size=14, weight="bold") + ).pack(anchor="w", padx=4, pady=(10, 2)) + frame = ctk.CTkFrame(parent) + frame.pack(fill="x", padx=4, pady=(0, 6)) + return frame + + +def _build_strava_connect( + strava: Any, client_id_var: Any, client_secret_var: Any, win: ctk.CTkToplevel, + cfg: dict[str, Any], config_mgr: Any, rebuild: Callable[[], None], + callback_needed: dict[str, bool], +) -> None: + ctk.CTkLabel( + strava, + text=( + "Create an API app at developers.strava.com and set the Authorization " + "Callback Domain to 127.0.0.1 (the app uses ports 8000-8009)." + ), + text_color=_MUTED, + wraplength=640, + justify="left", + ).pack(anchor="w", padx=8, pady=(8, 6)) + + ctk.CTkLabel(strava, text="Client ID").pack(anchor="w", padx=8) + ctk.CTkEntry(strava, textvariable=client_id_var).pack(fill="x", padx=8, pady=(0, 6)) + ctk.CTkLabel(strava, text="Client secret").pack(anchor="w", padx=8) + ctk.CTkEntry(strava, textvariable=client_secret_var, show="*").pack( + fill="x", padx=8, pady=(0, 8) + ) + + status = ctk.CTkLabel(strava, text="Not connected", text_color=_MUTED) + status.pack(anchor="w", padx=8, pady=(0, 6)) + + connect_btn = ctk.CTkButton(strava, text="Connect to Strava") + connect_btn.configure( + command=lambda: _connect_strava( + client_id_var, client_secret_var, status, connect_btn, win, + cfg, config_mgr, rebuild, callback_needed, + ) + ) + connect_btn.pack(fill="x", padx=8, pady=(0, 8)) + + +def _build_strava_connected( + strava: Any, cfg: dict[str, Any], config_mgr: Any, + rebuild: Callable[[], None], callback_needed: dict[str, bool], +) -> None: + ctk.CTkLabel( + strava, text="Connected", text_color="#4caf50", + font=ctk.CTkFont(size=14, weight="bold"), + ).pack(anchor="w", padx=8, pady=(8, 4)) + ctk.CTkLabel( + strava, text=f"Client ID: {cfg.get('strava_client_id', 'N/A')}", + text_color=_MUTED, + ).pack(anchor="w", padx=8, pady=(0, 8)) + ctk.CTkButton( + strava, text="Disconnect", fg_color=_DANGER, hover_color=_DANGER_HOVER, + command=lambda: _disconnect_strava(cfg, config_mgr, rebuild, callback_needed), + ).pack(fill="x", padx=8, pady=(0, 8)) + + +def _connect_strava( + client_id_var: Any, client_secret_var: Any, status: Any, connect_btn: Any, + win: ctk.CTkToplevel, cfg: dict[str, Any], config_mgr: Any, + rebuild: Callable[[], None], callback_needed: dict[str, bool], +) -> None: + client_id = client_id_var.get().strip() + client_secret = client_secret_var.get().strip() + if not client_id or not client_secret: + messagebox.showerror("Error", "Enter both Client ID and Client Secret.") + return + + status.configure(text="Opening browser… finish the authorization there.", text_color="#e0a23d") + connect_btn.configure(state="disabled") + + def manual_code_provider(auth_url: str) -> str | None: + """Prompt (on the GUI thread) for a pasted redirect URL / code, blocking the auth thread.""" + result_q: queue.Queue[str | None] = queue.Queue() + + def ask() -> None: + dialog = ctk.CTkInputDialog( + title="Finish connecting to Strava", + text=( + "Your browser didn't return automatically.\n\n" + "In the browser address bar after you authorized, copy the full URL " + "(or just the code= value) and paste it here:" + ), + ) + result_q.put(dialog.get_input()) + + win.after(0, ask) + return result_q.get() + + def auth_thread() -> None: + tokens = None + error_msg = None + try: + auth = StravaAuth(client_id, client_secret, log_level=cfg.get("log_level", "info")) + tokens = auth.start_auth_flow(manual_code_provider=manual_code_provider) + except Exception as e: + error_msg = str(e) + + def update_ui() -> None: + if tokens: + cfg.update({ + "strava_access_token": tokens["access_token"], + "strava_refresh_token": tokens["refresh_token"], + "strava_expires_at": tokens["expires_at"], + "strava_client_id": client_id, + "strava_client_secret": client_secret, + }) + config_mgr.save_config(cfg) + callback_needed["value"] = True + rebuild() + messagebox.showinfo("Success", "Connected to Strava.") + else: + status.configure(text="Connection failed", text_color="#e05a4d") + connect_btn.configure(state="normal") + messagebox.showerror( + "Error", + f"Authorization failed:\n{error_msg or 'Unknown error'}\n\n" + "Verify your Client ID and Secret at developers.strava.com.", + ) + + win.after(0, update_ui) + + threading.Thread(target=auth_thread, daemon=True).start() + + +def _disconnect_strava( + cfg: dict[str, Any], config_mgr: Any, + rebuild: Callable[[], None], callback_needed: dict[str, bool], +) -> None: + if not messagebox.askyesno("Confirm", "Disconnect from Strava and remove saved credentials?"): + return + cfg.update({ + "strava_access_token": "", "strava_refresh_token": "", "strava_expires_at": 0, + "strava_client_id": "", "strava_client_secret": "", + }) + try: + config_mgr.save_config(cfg) + except (ConfigError, OSError) as exc: + messagebox.showerror("Could not save settings", str(exc)) + return + callback_needed["value"] = True + rebuild() + messagebox.showinfo("Success", "Disconnected from Strava.") + + +def _submit( + cfg: dict[str, Any], config_mgr: Any, win: ctk.CTkToplevel, + fields: SettingsFields, on_save_callback: Callable[[], None] | None, +) -> None: + words_to_num = {v: k for k, v in config_mgr.DISPLAY_MAP.items()} + try: + numbers = [words_to_num[v.get()] for v in fields.display] + except KeyError: + messagebox.showerror("Error", "Invalid display layout selection.") + return + if len(numbers) != len(set(numbers)): + messagebox.showerror("Error", "Each display slot must be unique.") + return + + try: + boat = int(fields.boat_weight.get()) + person = int(fields.person_weight.get()) + except ValueError: + messagebox.showerror("Error", "Weights must be whole numbers.") + return + + try: + max_hr = int(fields.max_hr.get()) + except ValueError: + messagebox.showerror("Error", "Max HR must be a whole number.") + return + if not (100 <= max_hr <= 240): + messagebox.showerror("Error", "Max HR must be between 100 and 240 bpm.") + return + + try: + log_interval = int(fields.log_interval.get()) + target_grace = int(fields.target_grace.get()) + except ValueError: + messagebox.showerror( + "Error", "Log interval and target grace must be whole numbers." + ) + return + + hr_zone_mode = fields.hr_zone_mode.get() + try: + zone_bounds = [int(v.get()) for v in fields.hr_zones] + except ValueError: + messagebox.showerror("Error", "Zone bounds must be whole numbers.") + return + # Only enforce ordering/range when the manual zones are actually in use. + if hr_zone_mode == "manual": + if not all(zone_bounds[i] < zone_bounds[i + 1] for i in range(4)): + messagebox.showerror("Error", "Zone bounds must increase from Z1 to Z5.") + return + if not all(0 <= b <= 240 for b in zone_bounds): + messagebox.showerror("Error", "Zone bounds must be between 0 and 240 bpm.") + return + + def clean_mac(value: str) -> str: + return _extract_addr(value) + + cfg.update({ + "ergometer_name": fields.erg_name.get().strip(), + "ergometer_mac": clean_mac(fields.erg_mac.get()), + "hrm_name": fields.hrm_name.get().strip(), + "hrm_mac": clean_mac(fields.hrm_mac.get()), + "hrm_enabled": fields.hrm_enabled.get(), + "person_weight_default": person, + "boat_weight_default": boat, + "max_hr": max_hr, + "hr_zone_mode": hr_zone_mode, + "hr_zones": zone_bounds, + "display_config_numbers": numbers, + "log_interval": log_interval, + "log_level": fields.log_level.get(), + "auto_upload": fields.auto_upload.get(), + "sound_cues": fields.sound_cues.get(), + "target_grace_seconds": target_grace, + }) + + if not cfg.get("strava_refresh_token"): + cfg["strava_client_id"] = fields.client_id.get().strip() + cfg["strava_client_secret"] = fields.client_secret.get().strip() + + config_mgr.save_config(cfg) + win.destroy() + if on_save_callback: + on_save_callback() diff --git a/gui/controllers.py b/gui/controllers.py new file mode 100644 index 0000000..7c40614 --- /dev/null +++ b/gui/controllers.py @@ -0,0 +1,48 @@ +"""Small typed state owners used by the composed KayakFit GUI.""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class RecordingController: + """Mutable state for one recording and its stop-confirm lifecycle.""" + + active: bool = False + csv_path: str | None = None + points: int = 0 + incomplete: bool = False + persistence_error: str | None = None + paused: bool = False + lap: int = 0 + stop_armed: bool = False + stop_timer_id: str | None = None + + def reset_workout(self) -> None: + """Clear state derived from the current or most recent workout.""" + self.csv_path = None + self.points = 0 + self.incomplete = False + self.persistence_error = None + self.paused = False + self.lap = 0 + + +@dataclass +class ProgramController: + """State for program execution and target-adherence presentation.""" + + active: bool = False + target: dict[str, Any] | None = None + shown_target: str | None = None + pending_target: str | None = None + pending_count: int = 0 + ending_warned: bool = False + + +@dataclass +class ExportController: + """State for the single export/upload task and resulting activity.""" + + running: bool = False + strava_activity_id: int | None = None diff --git a/gui/dashboard_constants.py b/gui/dashboard_constants.py new file mode 100644 index 0000000..e182b9f --- /dev/null +++ b/gui/dashboard_constants.py @@ -0,0 +1,79 @@ +"""Shared visual constants for the KayakFit dashboard GUI. + +Color sets, tile definitions, HR-zone backgrounds and timing thresholds shared +by ``main_gui`` and its mixin modules. Kept in their own module so the mixins +and the main window import them from one place instead of from each other. +""" + +# (id, label, unit, show_stats) for each dashboard tile. show_stats adds an +# avg/max secondary line. +TILES = [ + ("time", "Active time", "", True), + ("distance", "Active distance", "m", False), + ("speed", "Live speed", "km/h", True), + ("pace", "Live pace", "", False), + ("stroke", "Live stroke rate", "spm", True), + ("hr", "Live heart rate", "bpm", True), + ("pull", "Live pull force", "N", True), + ("power", "Live power", "W · est.", True), +] + +DASHBOARD_COLS = 4 + +# Chip color sets as (light, dark) tuples for customtkinter. +CHIP_COLORS = { + "muted": (("#e6e6e6", "#2b2b2b"), ("#6f6f6f", "#9a9a9a")), + "info": (("#dceafc", "#11365e"), ("#1f6fd0", "#9fc6f5")), + "ok": (("#d8f3e3", "#16432f"), ("#1d7a4f", "#7ee0ab")), + "warn": (("#fbeccc", "#4a3508"), ("#9a6a06", "#f0c068")), +} +# Heart-rate zone tile backgrounds (light, dark), indexed by zone 0-4. +HR_ZONE_BG = [ + ("#e9e9e9", "#2b2b2b"), # 0 recovery + ("#dceafc", "#11365e"), # 1 easy + ("#d8f3e3", "#16432f"), # 2 aerobic + ("#fbeccc", "#4a3508"), # 3 threshold + ("#f7d7d2", "#5a1e16"), # 4 max +] +HR_COLOR = ("#c0392b", "#f0a098") +SUBTEXT_COLOR = ("#5f5f5f", "#9a9a9a") +# Muted color applied to tile values while a workout is auto-paused. +TILE_DIM_COLOR = ("#b4b4b4", "#565656") + +# Program step kind -> audible cue. Each kind sounds distinct so the athlete +# knows which change happened by ear; "effort" shares the "work" go-cue, and +# anything unmapped falls back to the generic "phase" tone. +STEP_KIND_CUES = { + "warmup": "warmup", + "work": "work", + "effort": "work", + "rest": "rest", + "cooldown": "cooldown", +} + +# Step-progress bar color per step kind, as (light, dark). Warm hues for effort, +# cool hues for easy phases, so the bar's color signals the phase at a glance. +# Unmapped kinds use STEP_COLOR_DEFAULT. +STEP_KIND_COLORS = { + "warmup": ("#e08a1e", "#f0b45a"), # amber — easing in + "work": ("#c0392b", "#e0685a"), # red — hard effort + "effort": ("#c0392b", "#e0685a"), # red — hard effort + "rest": ("#1f6fd0", "#5a9be8"), # blue — recovery + "cooldown": ("#2a9d8f", "#5cc9bb"), # teal — winding down +} +STEP_COLOR_DEFAULT = ("#1d7a4f", "#7ee0ab") # green — generic segment / complete + +# Program target-adherence badge colors: (background, text) as (light, dark). +TARGET_COLORS = { + "in": (("#d8f3e3", "#16432f"), ("#1d7a4f", "#7ee0ab")), # on target — green + "hard": (("#f7d7d2", "#5a1e16"), ("#c0392b", "#f0a098")), # too hard — red + "easy": (("#fbeccc", "#4a3508"), ("#9a6a06", "#f0c068")), # too easy — amber +} +TARGET_LABELS = {"in": "On target", "hard": "Too hard ↑", "easy": "Too easy ↓"} +DANGER_BTN = "#c0392b" +DANGER_BTN_HOVER = "#a02c20" + +# How long without a heart-rate reading before the HR tile is dimmed (seconds). +HR_STALE_SECONDS = 10 +# How long without any ergometer data before the live indicator warns (seconds). +DATA_STALE_SECONDS = 3 diff --git a/gui/device_scanner.py b/gui/device_scanner.py new file mode 100644 index 0000000..0fb27f7 --- /dev/null +++ b/gui/device_scanner.py @@ -0,0 +1,198 @@ +"""Bluetooth Low Energy device discovery for KayakFit. + +Intentionally UI-toolkit agnostic: the BLE scan runs on a background thread +and reports results and log messages through callbacks that the caller +marshals onto the GUI thread. It does not touch any widgets directly. +""" + +import asyncio +import contextlib +import threading +from collections.abc import Callable +from typing import Any + +from bleak import BleakClient, BleakScanner + +ScanResult = dict[str, Any] + +# Standard BLE Heart Rate Service. Any compliant strap (Garmin, Polar, Wahoo, +# COROS, …) advertises this UUID, so we identify HRMs by service rather than by +# device name — no name pattern needed. +HEART_RATE_SERVICE_UUID = "0000180d-0000-1000-8000-00805f9b34fb" + + +def test_connection( + address: str, + on_log: Callable[[str], None], + on_result: Callable[[dict[str, Any]], None], + schedule: Callable[[Callable[[], None]], None], + timeout: float = 15.0, +) -> threading.Thread: + """Briefly connect to a device to confirm it is reachable, then disconnect. + + Runs on a background thread and reports the outcome through callbacks the + caller marshals onto the GUI thread. + + Args: + address: Bluetooth MAC/UUID to test. + on_log: Called with progress messages (on the GUI thread). + on_result: Called once with ``{"ok": bool, "error": str|None}``. + schedule: Marshals a callable onto the GUI thread (e.g. ``window.after``). + timeout: Connection timeout in seconds. + + Returns: + The started daemon thread. + """ + + def run() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + result: dict[str, Any] = {} + try: + result = loop.run_until_complete(_async_test(address=address, timeout=timeout)) + except Exception as e: + result = {"ok": False, "error": str(e)} + finally: + _drain_loop(loop) + schedule(lambda: on_result(result)) + + schedule(lambda: on_log(f"Testing connection to {address}…\n")) + thread = threading.Thread(target=run, daemon=True) + thread.start() + return thread + + +async def _async_test(address: str, timeout: float) -> dict[str, Any]: + """Connect and immediately disconnect, reporting whether the link came up.""" + client = BleakClient(address_or_ble_device=address, timeout=timeout) + try: + await client.connect() + ok = bool(client.is_connected) + finally: + with contextlib.suppress(Exception): + await asyncio.wait_for(client.disconnect(), timeout=2.0) + return {"ok": ok, "error": None if ok else "Device did not connect"} + + +def scan_devices( + erg_pattern: str, + on_log: Callable[[str], None], + on_result: Callable[[ScanResult], None], + schedule: Callable[[Callable[[], None]], None], + stop_event: threading.Event | None = None, +) -> threading.Thread: + """Scan for Bluetooth devices on a background thread. + + Ergometers are matched by name (they are not a standard BLE profile); heart + rate monitors are detected by the advertised Heart Rate Service UUID, so no + HRM name pattern is required. + + Args: + erg_pattern: Substring to match ergometer device names (case-insensitive). + on_log: Called with human-readable progress messages (on the GUI thread). + on_result: Called once with the final result dict (on the GUI thread). + schedule: Marshals a callable onto the GUI thread (e.g. ``window.after``). + stop_event: Optional event to cancel the scan early. + + Returns: + The started scanner thread (daemon). + + The result dict has one of these shapes: + {"erg_devices": [(name, addr), ...], "hrm_devices": [...], "total": int} + {"cancelled": True} + {"error": "..."} + """ + + def run_scan() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + result: ScanResult = {} + try: + if stop_event and stop_event.is_set(): + result = {"cancelled": True} + else: + result = loop.run_until_complete( + _async_scan( + erg_pattern=erg_pattern, + stop_event=stop_event, + ) + ) + except Exception as e: + result = {"error": str(e)} + finally: + _drain_loop(loop) + + schedule(lambda: on_result(result)) + + schedule(lambda: on_log("Scanning for Bluetooth devices (~10s)…\n")) + scan_thread = threading.Thread(target=run_scan, daemon=True) + scan_thread.start() + return scan_thread + + +def _drain_loop(loop: asyncio.AbstractEventLoop) -> None: + """Cancel pending tasks and close the event loop without raising.""" + try: + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + with contextlib.suppress(asyncio.TimeoutError, Exception): + loop.run_until_complete( + asyncio.wait_for( + asyncio.gather(*pending, return_exceptions=True), + timeout=0.5, + ) + ) + loop.close() + except Exception: + pass + + +async def _async_scan( + erg_pattern: str, + stop_event: threading.Event | None = None, +) -> ScanResult: + """Discover BLE devices, matching ergometers by name and HRMs by service.""" + if stop_event and stop_event.is_set(): + return {"cancelled": True} + + erg_pattern = erg_pattern.strip().casefold() + + # return_adv=True gives us the advertisement data, which carries the list of + # advertised service UUIDs used to recognise heart-rate monitors. + discovered = await BleakScanner.discover(timeout=10.0, return_adv=True) + + if stop_event and stop_event.is_set(): + return {"cancelled": True} + + erg_devices: list[tuple[str, str]] = [] + hrm_devices: list[tuple[str, str]] = [] + + for device, adv in discovered.values(): + # Bleak does not expose the human-readable name consistently across + # platforms. On some systems it is only present in the advertisement; + # on others ``device.name`` is populated instead. Match both so the + # name printed on the ergometer works everywhere. + device_name = str(device.name or "").strip() + advertised_name = str(adv.local_name or "").strip() + name = advertised_name or device_name or "Unknown" + # CoreBluetooth may return an NSString/UUID proxy (or a ``str`` + # subclass) that looks like text but cannot be serialized by PyYAML. + # Convert it at the BLE boundary so configuration only sees built-ins. + address = str(device.address) + service_uuids = [u.lower() for u in (adv.service_uuids or [])] + + if HEART_RATE_SERVICE_UUID in service_uuids: + hrm_devices.append((name, address)) + elif erg_pattern and any( + erg_pattern in candidate.casefold() + for candidate in (advertised_name, device_name) + ): + erg_devices.append((name, address)) + + return { + "erg_devices": erg_devices, + "hrm_devices": hrm_devices, + "total": len(discovered), + } diff --git a/gui/device_status.py b/gui/device_status.py new file mode 100644 index 0000000..548716e --- /dev/null +++ b/gui/device_status.py @@ -0,0 +1,313 @@ +"""Device connection chips, HR-zone display, and status indicators. + +``DeviceStatusMixin`` is mixed into ``KayakFitGUI`` (see ``gui.main_gui``). It +defines no ``__init__`` and holds no state of its own; every ``self`` attribute +it uses is created on the main window. Those shared attributes/methods are +declared in ``gui._mixin_base.GuiSharedState``, which this mixin inherits under +``TYPE_CHECKING`` only (so mypy resolves them) — there is no runtime base class. +""" + +import contextlib +from typing import TYPE_CHECKING, Any, ClassVar + +import customtkinter as ctk + +from app import recovery, secret_store +from app.events import UiEvent + +from .dashboard_constants import ( + CHIP_COLORS, + HR_ZONE_BG, + SUBTEXT_COLOR, + TILE_DIM_COLOR, +) +from .metrics_format import hr_zone, zone_range + +# See gui/_mixin_base.py: type-checking-only shared surface for the composed GUI. +if TYPE_CHECKING: + from ._mixin_base import GuiSharedState as _MixinBase +else: + _MixinBase = object + + +class DeviceStatusMixin(_MixinBase): + """Ergometer/HRM/Strava chips, HR-zone tinting, segment + upload status.""" + + def _build_chips(self) -> None: + bar = ctk.CTkFrame(self.root, fg_color="transparent") + bar.grid(row=1, column=0, sticky="ew", padx=14, pady=(2, 0)) + + def chip() -> ctk.CTkLabel: + lbl = ctk.CTkLabel(bar, text="", corner_radius=8, padx=12, pady=4) + lbl.pack(side="left", padx=(0, 8)) + return lbl + + self.erg_chip = chip() + self.hrm_chip = chip() + self.strava_chip = chip() + + self._build_hr_legend(bar) + + # Heart-rate zone legend labels, indexed to match HR_ZONE_BG (zone 0-4). + HR_ZONE_LEGEND: ClassVar[list[tuple[str, str]]] = [ + ("Z1", "Recovery"), + ("Z2", "Easy"), + ("Z3", "Aerobic"), + ("Z4", "Threshold"), + ("Z5", "Max"), + ] + + def _build_hr_legend(self, bar: ctk.CTkFrame) -> None: + """Add a heart-rate zone legend (with bpm ranges) to the right of the chips. + + The tile keeps its full-background tint; this legend names each tint and + shows the bpm range for every zone, highlighting the active one. + """ + legend = ctk.CTkFrame(bar, fg_color="transparent") + self.hr_zone_legend = legend + legend.pack(side="right", padx=(8, 0)) + ctk.CTkLabel( + legend, + text="HR zones", + text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=12), + ).pack(side="left", padx=(0, 6)) + # Normal vs active fonts: the active zone is shown in bold (CTkLabel does + # not support a border, so weight is used to mark the current zone). + self._legend_font = ctk.CTkFont(size=11) + self._legend_font_active = ctk.CTkFont(size=11, weight="bold") + for zone, (short, _name) in enumerate(self.HR_ZONE_LEGEND): + lbl = ctk.CTkLabel( + legend, + text=short, + fg_color=HR_ZONE_BG[zone], + corner_radius=6, + padx=8, + pady=2, + font=self._legend_font, + ) + lbl.pack(side="left", padx=2) + self.hr_legend_labels[zone] = lbl + self._refresh_hr_legend() + + def _zones_cfg(self) -> list[int] | None: + """Return manual zone bounds when configured in manual mode, else None.""" + if str(self.cfg.get("hr_zone_mode", "auto")).lower() == "manual": + zones = self.cfg.get("hr_zones") or [] + return list(zones) if zones else None + return None + + def _refresh_hr_legend(self) -> None: + """Relabel each legend chip with its bpm range for the current config.""" + max_hr = self.cfg.get("max_hr", 185) + zones = self._zones_cfg() + for zone, lbl in self.hr_legend_labels.items(): + short, _name = self.HR_ZONE_LEGEND[zone] + low, high = zone_range(zone, max_hr, zones) + rng = f"{low}-{high}" if high is not None else f"{low}+" + lbl.configure(text=f"{short} {rng}") + + def _highlight_hr_legend(self, active: int) -> None: + """Mark the active zone chip in bold (active=-1 clears all).""" + for zone, lbl in self.hr_legend_labels.items(): + lbl.configure( + font=self._legend_font_active if zone == active else self._legend_font + ) + + def _set_chip(self, chip: ctk.CTkLabel, text: str, kind: str) -> None: + fg, txt = CHIP_COLORS.get(kind, CHIP_COLORS["muted"]) + chip.configure(text=text, fg_color=fg, text_color=txt) + + def _reset_device_chips(self) -> None: + """Return the ergometer and HRM chips to their idle (disconnected) state. + + Called when a workout ends or the dashboard is cleared so the chips stop + showing the last "streaming" / heart-rate value after the devices have + disconnected. + """ + erg = self.cfg.get("ergometer_name") or "not set" + self._set_chip(self.erg_chip, f"Ergometer · {erg}", "muted") + self._set_chip(self.hrm_chip, "HRM · idle", "muted") + + def _init_chips(self) -> None: + self._reset_device_chips() + connected = secret_store.has_complete_credentials(self.cfg) + self._set_chip( + self.strava_chip, + "Strava · connected" if connected else "Strava · not connected", + "info" if connected else "muted", + ) + + def _set_hr_zone(self, hr: int) -> None: + """Tint the HR tile, show the current zone + bpm range, mark the legend.""" + max_hr = self.cfg.get("max_hr", 185) + zones = self._zones_cfg() + zone = hr_zone(hr, max_hr, zones) + if 0 <= zone < len(HR_ZONE_BG): + self.tile_frames["hr"].configure(fg_color=HR_ZONE_BG[zone]) + _short, name = self.HR_ZONE_LEGEND[zone] + low, high = zone_range(zone, max_hr, zones) + rng = f"{low}-{high}" if high is not None else f"{low}+" + self.hr_zone_var.set(f"Z{zone + 1} {name} · {rng} bpm") + self._highlight_hr_legend(zone) + + def _clear_hr_zone(self) -> None: + self.tile_frames["hr"].configure(fg_color=self.tile_defaults["hr"]) + self.hr_zone_var.set("") + self._highlight_hr_legend(-1) + + def _apply_status(self, ev: UiEvent) -> None: + event = ev.get("event") + device = ev.get("device") + if event == "summary": + self._show_summary(ev) + return + if device == "session" and event == "segment": + self._apply_segment(ev) + return + if device == "session" and event == "program": + self._apply_program(ev) + return + if device == "session" and event == "inactivity_autostop": + minutes = ev.get("minutes", 30) + self._show_banner( + f"Auto-stopped after {minutes} min of inactivity — workout saved.", + "warn", + ) + self.log_message( + f"Workout auto-stopped after {minutes} min without movement.\n" + ) + return + if device == "session" and event == "persistence_error": + saved_rows = ev.get("saved_rows", 0) + self.recording.persistence_error = ( + f"Storage error — recording stopped after {saved_rows} saved rows. " + "Check available disk space and file permissions." + ) + self._show_banner( + self.recording.persistence_error, + "warn", + ) + self.log_message( + f"Workout storage failure after {saved_rows} saved rows: " + f"{ev.get('message', 'unknown error')}\n" + ) + return + if device == "hrm" and event == "battery": + self._apply_hrm_battery(ev.get("level")) + return + if device == "ergometer": + mapping = { + "connecting": ("connecting", "warn"), + "connected": ("connected", "info"), + "streaming": ("streaming", "ok"), + "reconnecting": ("reconnecting…", "warn"), + "stalled": ("no data", "warn"), + } + if event in mapping: + text, kind = mapping[event] + self._set_chip(self.erg_chip, f"Ergometer · {text}", kind) + if event == "reconnecting": + self._show_banner("Ergometer connection lost — reconnecting…", "warn") + elif event == "stalled": + self._show_banner("Ergometer connected but sending no data…", "warn") + elif event in ("connected", "streaming"): + self._hide_banner() + elif device == "hrm": + if event == "connecting": + self._set_chip(self.hrm_chip, "HRM · connecting", "warn") + elif event == "connected": + self._set_chip(self.hrm_chip, "HRM · connected", "ok") + self._hide_banner() + elif event == "reconnecting": + self._set_chip(self.hrm_chip, "HRM · reconnecting…", "warn") + self._show_banner("Heart-rate strap lost — reconnecting…", "warn") + elif event == "absent": + self._set_chip(self.hrm_chip, "HRM · not used", "muted") + + def _apply_hrm_battery(self, level: Any) -> None: + """Record the HRM battery level and flag a low strap once.""" + try: + pct = int(level) + except (TypeError, ValueError): + return + self._hrm_battery = pct + self.log_message(f"HRM battery: {pct}%\n") + if pct <= 15: + self._show_banner( + f"Heart-rate strap battery low ({pct}%) — consider replacing it.", + "warn", + ) + + def _apply_segment(self, ev: UiEvent) -> None: + """Reflect a live autopause / lap transition from the session.""" + self.recording.paused = bool(ev.get("paused")) + # Auto-pause/resume is intentionally silent (dimmed tiles show it): the + # athlete already knows when they stop and start paddling. Cues are + # reserved for program changes the athlete can't otherwise anticipate. + lap = ev.get("lap") + if isinstance(lap, int) and lap > 0: + self.recording.lap = lap + # The live lap counter is movement-based (autopause segments). For a + # program workout the summary screen and FIT file report laps + # step-based instead, so a live "Lap N" chip could diverge from + # (and appear to contradict) what the athlete sees afterwards — the + # program panel already shows step-based live progress. Show the + # chip only for free workouts, whose laps ARE these segments. + if not self.program.active: + self.lap_label.configure(text=f"Lap {lap}") + if self.recording.active and not self.lap_label.winfo_ismapped(): + self.lap_label.pack(side="left", padx=(0, 12)) + self._set_tiles_dimmed(self.recording.paused) + self._update_live_label() + + def _set_tiles_dimmed(self, dim: bool) -> None: + """Mute (or restore) the big tile numbers to signal an auto-pause.""" + if dim == self._tiles_dimmed: + return + self._tiles_dimmed = dim + for key, label in self.tile_value_labels.items(): + target = TILE_DIM_COLOR if dim else self.tile_value_colors[key] + with contextlib.suppress(Exception): # pragma: no cover - widget teardown race + label.configure(text_color=target) + + def _restore_tile_brightness(self) -> None: + """Undo any pause dimming (used when recording stops or resets).""" + self._set_tiles_dimmed(False) + + def _apply_upload(self, ev: UiEvent) -> None: + if ev.get("pending"): + self._show_banner( + ev.get("message") or "Upload accepted and still processing on Strava.", + "info", + ) + elif ev.get("success"): + # A FIT now exists (saved and/or uploaded), so the workout is safe; + # drop any pending crash-recovery marker. + try: + recovery.clear_active() + except OSError as exc: + self._show_banner( + f"FIT saved, but recovery state could not be cleared — {exc}", + "warn", + ) + elif ev.get("message"): + # Surface a failed upload/conversion instead of hiding it in the log. + self._show_banner(f"Export/upload failed — {ev['message']}", "warn") + if ev.get("success") and ev.get("activity_id"): + self.export.strava_activity_id = ev["activity_id"] + # Upload done: the "Upload to Strava" button is now redundant, so + # replace it in place with "View on Strava" (fall back to a plain + # right-pack when the action row isn't shown, e.g. recovery export). + self.upload_btn.pack_forget() + if self.view_summary_btn.winfo_ismapped(): + self.strava_btn.pack( + side="right", padx=(0, 12), pady=12, before=self.view_summary_btn + ) + else: + self.strava_btn.pack(side="right", padx=(0, 12), pady=12) + if not self.summary_card.winfo_ismapped(): + self.summary_label.configure(text="Uploaded to Strava.") + self.summary_card.grid( + row=5, column=0, sticky="ew", padx=14, pady=(0, 6) + ) diff --git a/gui/export_upload.py b/gui/export_upload.py new file mode 100644 index 0000000..4b454eb --- /dev/null +++ b/gui/export_upload.py @@ -0,0 +1,133 @@ +"""FIT export, Strava upload actions, and config reload. + +``ExportUploadMixin`` is mixed into ``KayakFitGUI`` (see ``gui.main_gui``). It +defines no ``__init__`` and holds no state of its own; every ``self`` attribute +it uses is created on the main window. Those shared attributes/methods are +declared in ``gui._mixin_base.GuiSharedState``, which this mixin inherits under +``TYPE_CHECKING`` only (so mypy resolves them) — there is no runtime base class. +""" + +import contextlib +import webbrowser +from typing import TYPE_CHECKING, Any + +from app import secret_store +from app.config import get_bool +from app.events import UiEvent + +# See gui/_mixin_base.py: type-checking-only shared surface for the composed GUI. +if TYPE_CHECKING: + from ._mixin_base import GuiSharedState as _MixinBase +else: + _MixinBase = object + + +class ExportUploadMixin(_MixinBase): + """Export/upload worker wiring, Strava helpers, and config reload.""" + + def _strava_connected(self) -> bool: + return secret_store.has_complete_credentials(self.cfg) + + def _open_strava_activity(self) -> None: + if self.export.strava_activity_id: + webbrowser.open( + f"https://www.strava.com/activities/{self.export.strava_activity_id}" + ) + + def _export_args(self, path: str, upload: bool) -> dict[str, Any]: + """Build the argument dict for the export/upload worker.""" + return { + "file_path": path, + "access_token": self.cfg.get("strava_access_token", ""), + "refresh_token": self.cfg.get("strava_refresh_token", ""), + "client_id": self.cfg.get("strava_client_id", ""), + "client_secret": self.cfg.get("strava_client_secret", ""), + "strava_upload": "yes" if upload else "no", + } + + def _set_export_buttons(self, enabled: bool) -> None: + state = "normal" if enabled else "disabled" + for btn in (self.upload_btn, self.savefit_btn, self.discard_btn): + with contextlib.suppress(Exception): + btn.configure(state=state) + if enabled and not self.recording.active: + self.workouts_btn.configure(state="normal") + elif not enabled: + self.workouts_btn.configure(state="disabled") + + def _start_export( + self, path: str | None, upload: bool, announce: bool = False + ) -> None: + """Convert (and optionally upload) a workout file without a file picker.""" + if not path: + return + if self.export.running or (self.worker_mgr and self.worker_mgr.is_running()): + self.log_message("A task is already running; please wait.\n") + return + if upload and not self._strava_connected(): + self.log_message( + "Strava is not connected. Open Settings to connect first.\n" + ) + upload = False + + prefix = "Auto-export: " if announce else "" + self.log_message( + prefix + + ( + "Uploading workout to Strava...\n" + if upload + else "Saving workout as FIT...\n" + ) + ) + self.strava_btn.pack_forget() + # Only mark the export as running once the worker actually started, so + # a refused start can never leave _export_running stuck True. + if self.worker_mgr is not None and self.worker_mgr.start_worker( + worker_type="export_upload", args=self._export_args(path, upload) + ): + self.export.running = True + self._set_export_buttons(enabled=False) + + def _auto_export(self, attempts: int = 20) -> None: + """After a workout, always produce a FIT (and auto-upload if configured). + + The workout worker thread can still be tearing down when this first + fires; retry until it has exited so the export is never silently + dropped. + """ + if not self.recording.csv_path or self.recording.points <= 0: + return + if self.recording.incomplete: + self.log_message( + "Automatic export skipped because recording stopped after a storage " + "failure; review the retained partial workout first.\n" + ) + return + if self.worker_mgr is not None and self.worker_mgr.is_running(): + if attempts > 0: + self.root.after(500, lambda: self._auto_export(attempts - 1)) + else: + self.log_message( + "Auto-export skipped (a task is still running); " + "use the Workouts window to export it.\n" + ) + return + auto_upload = get_bool(self.cfg, "auto_upload", default=False) + self._start_export( + self.recording.csv_path, + upload=auto_upload and self._strava_connected(), + announce=True, + ) + + def _reload_cfg(self) -> None: + """Reload the config from disk, keeping in-memory UI preferences. + + The pace-distance choice is only persisted on exit, so a reload (after + saving settings or a token refresh) must not silently revert it. + """ + self.cfg = self.config_mgr.load_config() + self.cfg["pace_distance"] = self._pace_distance + + def _on_tokens_updated(self, result: UiEvent) -> None: + self._reload_cfg() + self._init_chips() diff --git a/gui/history_window.py b/gui/history_window.py new file mode 100644 index 0000000..9f50af5 --- /dev/null +++ b/gui/history_window.py @@ -0,0 +1,513 @@ +"""Workouts Window. + +The single place for everything about past workouts. It lists recorded +sessions from ``~/KayakFit/`` (newest first); clicking a row +selects it and a single action bar at the bottom offers **Summary**, +**Save FIT** and **Upload** for the selected file. "Choose file…" adds a +workout CSV or FIT stored elsewhere to the list (pre-selected), using the same +actions as a library workout. + +Button rules: + - Summary: CSV only (the summary is computed from the raw CSV log). + - Save FIT: CSV only (a .fit file needs no conversion). + - Upload: CSV or FIT, requires a connected Strava account. + +UI-toolkit code only; all export/upload work is delegated back to the caller +through the ``start_export`` callback so this window stays decoupled from the +worker plumbing. Cross-platform (Windows / macOS / Linux). +""" + +import contextlib +import os +import tkinter as tk +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from tkinter import filedialog +from typing import Any, Literal + +import customtkinter as ctk + +from app.workout_metadata import load_workout_metadata +from app.workout_paths import WORKOUT_DIRECTORY_PREFIX, WorkoutPaths +from gui.window_utils import center_over_parent, open_in_file_manager + +StartExport = Callable[[str, bool], None] +OpenSummary = Callable[[str], None] + +# Row highlight colors (light mode, dark mode). +_ROW_SELECTED = ("#cfe3f5", "#1f4260") +_ROW_NORMAL = "transparent" +# Muted hint text, matching the main dashboard's subtext color (light, dark). +_MUTED = ("#5f5f5f", "#9a9a9a") +# Compact workout-mode badges. These are classifications, not success states, +# so both palettes remain restrained in light and dark appearance modes. +_BADGE_STYLE = { + "planned": ("Plan", ("#dceafa", "#193c5a"), ("#245b8f", "#a9d2f5")), + "free": ("Free", ("#e8e8e8", "#343434"), ("#555555", "#b6b6b6")), + "fit": ("FIT", ("#ece8f3", "#393443"), ("#655879", "#c5b9d7")), + "invalid": ("Invalid", ("#f3dddd", "#4a2929"), ("#8a3030", "#f0aaaa")), +} +_INITIAL_ROW_COUNT = 24 +_ROW_BATCH_SIZE = 24 +# Keep the library responsive even when a long-running installation has years +# of recordings. Files outside the library remain available through Choose file…. +_MAX_LISTED_WORKOUTS = 200 + +WorkoutType = Literal["planned", "free", "fit", "invalid"] + + +@dataclass +class HistoryCatalog: + """Selection and deduplication state independent of window construction.""" + + selected_path: Path | None = None + selected_row: Any | None = None + rows_by_key: dict[str, Any] = field(default_factory=dict) + paths_by_key: dict[str, Path] = field(default_factory=dict) + types_by_key: dict[str, WorkoutType] = field(default_factory=dict) + + @staticmethod + def key(path: Path) -> str: + """Return a normalized key for path deduplication.""" + try: + return os.path.normcase(str(path.resolve())) + except OSError: + return os.path.normcase(str(path)) + + def register(self, path: Path, row: Any, workout_type: WorkoutType) -> None: + """Register one rendered path and its view row.""" + key = self.key(path) + self.rows_by_key[key] = row + self.paths_by_key[key] = path + self.types_by_key[key] = workout_type + + +def _open_in_file_manager(path: str) -> None: + """Reveal a folder in the OS file manager (Finder / Explorer / xdg).""" + with contextlib.suppress(Exception): + open_in_file_manager(path) + + +def list_workouts( + workouts_dir: Path, limit: int = _MAX_LISTED_WORKOUTS +) -> list[Path]: + """Return a bounded newest-first list of canonical workout CSVs.""" + try: + year_dirs = sorted( + ( + path + for path in workouts_dir.iterdir() + if path.is_dir() and path.name.isdigit() and len(path.name) == 4 + ), + key=lambda path: path.name, + reverse=True, + ) + except OSError: + return [] + + def _mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except OSError: # deleted between listing and stat + return 0.0 + + files: list[Path] = [] + for year_dir in year_dirs: + try: + workout_dirs = [ + path for path in year_dir.iterdir() + if path.is_dir() and path.name.startswith(WORKOUT_DIRECTORY_PREFIX) + ] + except OSError: + continue + for workout_dir in workout_dirs: + csv_path = WorkoutPaths(workout_dir).csv + if csv_path.is_file(): + files.append(csv_path) + files.sort(key=_mtime, reverse=True) + return files[:limit] + + +def _format_size(num_bytes: int) -> str: + kb = num_bytes / 1024 + return f"{kb:,.0f} KB" if kb < 1024 else f"{kb / 1024:,.1f} MB" + + +def _file_details(path: Path) -> str: + """'2026-07-03 09:31 · 12 KB' (em-dashes when the file is unreadable).""" + try: + stat = path.stat() + when = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M") + return f"{when} · {_format_size(stat.st_size)}" + except OSError: + return "— · —" + + +def _display_name(path: Path) -> str: + """Return the workout directory name, or an external file name.""" + return ( + path.parent.name + if path.suffix.lower() == ".csv" + and path.parent.name.startswith(WORKOUT_DIRECTORY_PREFIX) + else path.name + ) + + +def _workout_type(path: Path) -> WorkoutType: + """Classify a v1 history item from its required metadata.""" + if path.suffix.lower() != ".csv": + return "fit" + try: + mode = load_workout_metadata(path)["workout_mode"] + except ValueError: + return "invalid" + return "planned" if mode == "planned" else "free" + + +def open_history_window( + root: ctk.CTk, + workouts_dir: Path, + start_export: StartExport, + strava_connected: bool, + open_summary: OpenSummary | None = None, +) -> None: + """Open the Workouts window. + + Args: + root: Parent window. + workouts_dir: Directory containing recorded workouts. + start_export: Callback ``(path, upload)`` that converts/uploads a file. + strava_connected: Whether the upload action should be enabled. + open_summary: Optional callback ``(path)`` that opens the workout + summary screen for a CSV (the button is disabled when None). + """ + win = ctk.CTkToplevel(master=root) + # Building up to 200 CustomTkinter rows is not instantaneous. Keep the + # toplevel unmapped until its first screenful has final geometry so users + # never see badges appear at the left and jump into place one row at a time. + win.withdraw() + win.title("Workouts") + # Preserve a practical list area and full-width action controls at the + # default size. + win.minsize(700, 540) + win.transient(root) + grab_job: str | None = None + + # ---- selection state ---------------------------------------------------- + catalog = HistoryCatalog() + + # ---- header --------------------------------------------------------------- + header = ctk.CTkFrame(win, fg_color="transparent") + header.pack(fill="x", padx=14, pady=(12, 4)) + ctk.CTkLabel( + header, text="Workouts", font=ctk.CTkFont(size=16, weight="bold") + ).pack(side="left") + ctk.CTkButton( + header, text="Open folder", width=110, + command=lambda: _open_in_file_manager(str(workouts_dir)), + ).pack(side="right") + search_var = tk.StringVar(value="") + search_entry = ctk.CTkEntry( + header, textvariable=search_var, placeholder_text="Search workouts…", width=210 + ) + search_entry.pack(side="right", padx=8) + + # ---- action bar (packed before the list so it never scrolls away) -------- + actions = ctk.CTkFrame(win, fg_color="transparent") + actions.pack(side="bottom", fill="x", padx=14, pady=(4, 12)) + action_info = ctk.CTkFrame(actions, fg_color="transparent") + action_info.pack(fill="x") + info_group = ctk.CTkFrame(action_info, fg_color="transparent") + info_group.pack(anchor="center") + action_buttons = ctk.CTkFrame(actions, fg_color="transparent") + action_buttons.pack(fill="x", pady=(6, 0)) + button_group = ctk.CTkFrame(action_buttons, fg_color="transparent") + button_group.pack(anchor="center") + + ctk.CTkLabel( + info_group, + text=f"Library: newest {_MAX_LISTED_WORKOUTS} workouts", + text_color=_MUTED, + font=ctk.CTkFont(size=11), + ).pack(side="left", padx=(0, 12)) + hint = ctk.CTkLabel( + info_group, + text="Select a workout · ↑↓ select · Enter summary", + text_color=_MUTED, + anchor="center", + ) + hint.pack(side="left") + + upload_btn = ctk.CTkButton( + button_group, text="Upload", width=100, state="disabled" + ) + upload_btn.pack(side="left") + save_fit_btn = ctk.CTkButton( + button_group, text="Save FIT", width=100, state="disabled" + ) + save_fit_btn.pack(side="left", padx=8) + summary_btn = ctk.CTkButton( + button_group, text="Summary", width=100, state="disabled" + ) + summary_btn.pack(side="left") + + # ---- list ----------------------------------------------------------------- + container = ctk.CTkScrollableFrame(win) + container.pack(fill="both", expand=True, padx=14, pady=(4, 4)) + container.grid_columnconfigure(0, weight=1) + + # ---- actions ---------------------------------------------------------------- + def act(upload: bool) -> None: + """Hand the selected file to the shared export path and close.""" + if catalog.selected_path is None: + return + start_export(str(catalog.selected_path), upload) + win.destroy() + + def show_summary() -> None: + """Open the summary screen for the selected workout. + + This window holds a modal grab; release it first or the summary + window would not receive any mouse/keyboard events. The Workouts + window stays open (non-modal) so more summaries can be opened. + """ + path = catalog.selected_path + if path is None or open_summary is None or path.suffix.lower() != ".csv": + return # also guards the double-click shortcut on a FIT row + try: + if grab_job is not None: + win.after_cancel(grab_job) # grab may not have been applied yet + win.grab_release() + except Exception: + pass + open_summary(str(path)) + + summary_btn.configure(command=show_summary) + save_fit_btn.configure(command=lambda: act(False)) + upload_btn.configure(command=lambda: act(True)) + + def refresh_actions() -> None: + """Enable each button according to the selected file type.""" + path = catalog.selected_path + if path is None: + for btn in (summary_btn, save_fit_btn, upload_btn): + btn.configure(state="disabled") + hint.configure(text="Select a workout · ↑↓ select · Enter summary") + return + is_csv = path.suffix.lower() == ".csv" + summary_btn.configure( + state="normal" if (is_csv and open_summary is not None) else "disabled" + ) + save_fit_btn.configure(state="normal" if is_csv else "disabled") + upload_btn.configure(state="normal" if strava_connected else "disabled") + if not strava_connected: + hint.configure(text=f"{_display_name(path)} · connect Strava to upload") + elif not is_csv: + hint.configure(text=f"{_display_name(path)} · already a FIT file") + else: + hint.configure(text=_display_name(path)) + + def select(path: Path, row: ctk.CTkFrame) -> None: + if catalog.selected_row is not None: + catalog.selected_row.configure(fg_color=_ROW_NORMAL) + catalog.selected_path, catalog.selected_row = path, row + row.configure(fg_color=_ROW_SELECTED) + refresh_actions() + + def visible_items() -> list[tuple[Path, ctk.CTkFrame]]: + """Return rendered rows matching the current lightweight filter.""" + query = search_var.get().strip().casefold() + items: list[tuple[Path, ctk.CTkFrame]] = [] + for key, row in catalog.rows_by_key.items(): + path = catalog.paths_by_key[key] + workout_type = catalog.types_by_key[key] + matched = ( + not query + or query in _display_name(path).casefold() + or query in workout_type + ) + if matched: + row.pack(fill="x", padx=4, pady=2) + items.append((path, row)) + else: + row.pack_forget() + return items + + def apply_filter(*_args: Any) -> None: + items = visible_items() + if catalog.selected_row is not None and not catalog.selected_row.winfo_ismapped(): + catalog.selected_path, catalog.selected_row = None, None + if catalog.selected_row is None and items: + select(*items[0]) + else: + refresh_actions() + + def add_row(path: Path, external: bool = False, at_top: bool = False) -> ctk.CTkFrame: + """Add one selectable file row (no per-row buttons by design).""" + row = ctk.CTkFrame(container, corner_radius=8, fg_color=_ROW_NORMAL) + anchor_widget = _first_row() if at_top else None + if anchor_widget is not None and anchor_widget is not row: + row.pack(fill="x", padx=4, pady=2, before=anchor_widget) + else: + row.pack(fill="x", padx=4, pady=2) + + title = ctk.CTkFrame(row, fg_color="transparent") + title.pack(fill="x", padx=10, pady=(6, 0)) + title.grid_columnconfigure(0, weight=1) + name = ctk.CTkLabel( + title, + text=_display_name(path), + font=ctk.CTkFont(size=13, weight="bold"), + anchor="w", + ) + name.grid(row=0, column=0, sticky="ew") + workout_type = _workout_type(path) + badge_text, badge_bg, badge_text_color = _BADGE_STYLE[workout_type] + badge = ctk.CTkLabel( + title, + text=badge_text, + width=48, + height=20, + corner_radius=7, + fg_color=badge_bg, + text_color=badge_text_color, + font=ctk.CTkFont(size=11, weight="bold"), + ) + badge.grid(row=0, column=1, sticky="e", padx=(8, 0)) + details = _file_details(path) + if external: + details += f" · {path.parent}" # show where an outside file lives + sub = ctk.CTkLabel( + row, text=details, text_color=_MUTED, font=ctk.CTkFont(size=12), anchor="w" + ) + sub.pack(anchor="w", padx=10, pady=(0, 6)) + + # Clicks on the frame or either label all select; double-click is a + # shortcut for Summary. Children don't propagate events in Tk, so each + # widget is bound explicitly. + for widget in (row, title, name, badge, sub): + widget.bind("", lambda _e, p=path, r=row: select(p, r)) + widget.bind("", lambda _e: show_summary()) + catalog.register(path, row, workout_type) + return row + + def _first_row() -> ctk.CTkFrame | None: + children = container.winfo_children() + return children[0] if children else None + + def choose_file() -> None: + """Add an external workout CSV or FIT file.""" + chosen = filedialog.askopenfilename( + parent=win, + title="Select workout file", + initialdir=str(workouts_dir), + filetypes=[ + ("Workout files", "*.csv *.fit"), + ("CSV files", "*.csv"), + ("FIT files", "*.fit"), + ], + ) + if not chosen: + return + path = Path(chosen) + if path.suffix.lower() not in (".csv", ".fit"): + return + # If this file is already listed, just select it instead of duplicating. + existing = catalog.rows_by_key.get(catalog.key(path)) + row = existing if existing is not None else add_row( + path, external=True, at_top=True + ) + select(path, row) + + ctk.CTkButton( + header, text="Choose file…", width=110, command=choose_file, + ).pack(side="right", padx=(0, 8)) + + def move_selection(delta: int) -> str: + items = visible_items() + if not items: + return "break" + paths = [path for path, _row in items] + selected = catalog.selected_path + try: + index = paths.index(selected) if selected is not None else 0 + except ValueError: + index = 0 + index = min(max(index + delta, 0), len(items) - 1) + select(*items[index]) + return "break" + + search_var.trace_add("write", apply_filter) + win.bind("", lambda _event: win.destroy()) + win.bind("", lambda _event: move_selection(-1)) + win.bind("", lambda _event: move_selection(1)) + win.bind("", lambda _event: show_summary()) + + # ---- populate --------------------------------------------------------------- + workouts = list_workouts(workouts_dir) + if not workouts: + ctk.CTkLabel( + container, + text="No workouts recorded yet.\n" + "Use “Choose file…” to work with a workout stored elsewhere.", + text_color=_MUTED, + justify="left", + ).pack(anchor="w", padx=8, pady=12) + else: + for path in workouts[:_INITIAL_ROW_COUNT]: + add_row(path) + # Pre-select the most recent workout so the buttons are ready to use. + first = _first_row() + if first is not None: + select(workouts[0], first) + + next_workout = min(len(workouts), _INITIAL_ROW_COUNT) + + def populate_older_rows() -> None: + """Append older rows in bounded batches without blocking first paint.""" + nonlocal next_workout + end = min(next_workout + _ROW_BATCH_SIZE, len(workouts)) + for path in workouts[next_workout:end]: + # A user can choose a not-yet-rendered canonical file while batches + # are pending. Do not add it again when its normal turn arrives. + if catalog.key(path) not in catalog.rows_by_key: + add_row(path) + next_workout = end + if next_workout < len(workouts) and win.winfo_exists(): + win.after(1, populate_older_rows) + + # A CTkScrollableFrame learns its true content width from a + # event on its internal Canvas, which only occurs after the toplevel is + # mapped. Map at zero opacity first; revealing a withdrawn window directly + # would briefly draw every badge against the rows' small requested width. + center_over_parent(win, root, 700, 540) + transparent_layout = True + try: + win.attributes("-alpha", 0.0) + except Exception: + # Alpha is supported on the packaged macOS/Windows targets and common + # X11 window managers. The fallback still works, but cannot conceal a + # window-manager-specific first mapped layout pass. + transparent_layout = False + win.deiconify() + + def finish_reveal() -> None: + """Show the window after its mapped Canvas has propagated real width.""" + nonlocal grab_job + if not win.winfo_exists(): + return + win.update_idletasks() + if transparent_layout: + win.attributes("-alpha", 1.0) + win.lift() + if next_workout < len(workouts): + win.after(1, populate_older_rows) + # A Toplevel cannot grab before it is viewable; keep the id so the + # summary action can cancel a not-yet-applied grab. + grab_job = win.after(200, win.grab_set) + + # One short mapped frame lets Tk deliver the scrollable Canvas + # event before opacity changes. It is imperceptible but removes the jump. + win.after(20, finish_reveal) diff --git a/gui/main_gui.py b/gui/main_gui.py new file mode 100644 index 0000000..cf896df --- /dev/null +++ b/gui/main_gui.py @@ -0,0 +1,742 @@ +"""Primary user interface for KayakFit, built with customtkinter. + +During a workout the window shows a live metric dashboard fed by recorded +sensor samples; a compact, collapsible log keeps the detailed output +available. + +The heavy lifting (BLE, CSV, FIT export, Strava) runs in background workers via +WorkerManager, which pushes structured UI events back onto the GUI thread. + +The window's behaviour is split across focused mixin modules +(``program_panel``, ``device_status``, ``recording_lifecycle``, +``export_upload``); this file keeps construction (``__init__`` + the +layout-building methods) and the small cross-cutting helpers (log panel, +banner, appearance, history/settings windows, closing). The attributes and +methods those mixins share are declared once for the type checker in +``gui._mixin_base.GuiSharedState`` (a ``TYPE_CHECKING``-only base, no runtime +effect). +""" + +import contextlib +import tkinter as tk +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import customtkinter as ctk + +from app import program as program_mod +from app.config import get_bool +from app.keep_awake import ScreenAwake +from app.logger import Logger +from app.sound import SoundPlayer +from app.speed_series import LiveSpeedTracker + +from .config_manager import ConfigManager +from .config_window import open_config_window +from .controllers import ExportController, ProgramController, RecordingController +from .dashboard_constants import ( + CHIP_COLORS, + DASHBOARD_COLS, + HR_COLOR, + SUBTEXT_COLOR, + TILES, +) +from .device_status import DeviceStatusMixin +from .export_upload import ExportUploadMixin +from .history_window import open_history_window +from .program_panel import ProgramPanelMixin +from .recording_lifecycle import RecordingLifecycleMixin +from .summary_window import open_summary_window +from .window_utils import center_on_screen, open_in_file_manager +from .worker_manager import WorkerManager + + +class KayakFitGUI( + ProgramPanelMixin, + DeviceStatusMixin, + RecordingLifecycleMixin, + ExportUploadMixin, +): + """Main application window.""" + + MAX_LOG_LINES = 2000 + + def __init__( + self, root: ctk.CTk, workout_worker: Callable[..., Any], export_worker: Callable[..., Any] + ) -> None: + self.root = root + self.root.title("KayakFit · KayakFirst Bull Workout Companion") + + min_width, min_height = 860, 640 + center_on_screen(self.root, min_width, min_height) + self.root.minsize(min_width, min_height) + + self.config_mgr = ConfigManager() + self.cfg: dict[str, Any] = self.config_mgr.load_config() + appearance = str(self.cfg.get("appearance_mode", "system")).lower() + if appearance in ("system", "light", "dark"): + ctk.set_appearance_mode(appearance) + + self.recording = RecordingController() + self.program = ProgramController() + self.export = ExportController() + self._log_visible = bool(self.cfg.get("activity_log_visible", True)) + + self.boat_weight_var = tk.StringVar( + value=str(self.cfg.get("boat_weight_default", 12)) + ) + self.person_weight_var = tk.StringVar( + value=str(self.cfg.get("person_weight_default", 75)) + ) + self.metric_vars = {key: tk.StringVar(value="—") for key, _, _, _ in TILES} + self.metric_sub_vars = { + key: tk.StringVar(value="") for key, _, _, stats in TILES if stats + } + # Current HR zone label (e.g. "Z3 Aerobic · 140-157 bpm") shown on the HR tile. + self.hr_zone_var = tk.StringVar(value="") + self.hr_legend_labels: dict[int, ctk.CTkLabel] = {} + + # Per-tile widget refs (for HR-zone recoloring) and running stats. + self.tile_frames: dict[str, ctk.CTkFrame] = {} + self.tile_defaults: dict[str, Any] = {} + self.tile_value_labels: dict[str, ctk.CTkLabel] = {} + self.tile_value_colors: dict[str, Any] = {} + self._tiles_dimmed = False + self._stats: dict[str, dict[str, Any]] = {} + # Active time/distance tracker; finalized speed remains available for + # reconciliation and dashboard speed cards use instantaneous device fields. + self._live_speed = LiveSpeedTracker() + + # Liveness / staleness timing (monotonic seconds). + self._last_data_ts: float | None = None + self._last_hr_ts: float | None = None + self._hr_dimmed = False + + # Live autopause / lap state (driven by the session's segmenter). + # Whether the current recording is driven by a structured program. Set + # when a workout starts. A program workout's laps are step-based in the + # post-workout summary and FIT file, so the live movement-based lap + # counter is suppressed for it (see _apply_segment) to avoid showing a + # lap count that later contradicts the summary. + + # Latest HRM battery level (%), shown on the HRM chip when available. + self._hrm_battery: int | None = None + + # Current program step's intensity target and adherence tracking. + # The badge only changes once a new status has held for the grace period, + # so second-to-second fluctuation doesn't make it flicker. + # Fires an audible heads-up once, near the end of the current step, so + # the athlete knows to prepare to stop without watching the screen. + + # Training programs: name -> Program, plus the current selection. + # The bundled example plans are copied into ~/KayakFit/programs once + # (first run) so they live alongside — and are editable like — the + # user's own plans. + self._free_label = "Free workout" + # Collect per-file load issues so they can be surfaced in the activity + # log once the log panel exists (it is built later, in _build_layout). + self._pending_program_warnings: list[str] = [] + self._program_warn_sig: tuple[Any, ...] = () + try: + program_mod.install_examples() + warnings: list[str] = [] + self._programs = { + p.identifier: p for p in program_mod.list_programs(warnings) + } + self._pending_program_warnings = warnings + self._program_warn_sig = tuple(warnings) + except Exception: + self._programs = {} + self._program_labels = self._build_program_labels() + self.program_var = ctk.StringVar(value=self._free_label) + + # Two-tap stop confirmation guards against an accidental single tap or + # spacebar press ending (and saving) the workout. + + appearance_label = appearance.capitalize() + self.appearance_var = ctk.StringVar(value=appearance_label) + + # Pace tile: switchable competition distance (200 / 500 / 1000 m). + pace_d = self.cfg.get("pace_distance", 500) + self._pace_distance = pace_d if pace_d in (200, 500, 1000) else 500 + self.pace_distance_var = ctk.StringVar(value=f"{self._pace_distance} m") + self._latest_data: dict[str, Any] = {} + + self.worker_mgr: WorkerManager | None = None + + # Keeps the display from sleeping while a workout is recording. + self._screen_awake = ScreenAwake(log=self.log_message) + + # Audible cues on workout phase changes. + self._sound = SoundPlayer(enabled=get_bool(self.cfg, "sound_cues", default=True)) + + self._build_layout() + + # Now that the activity log exists, surface any plan-load issues from + # startup (the detailed per-file reasons are in the logs). + self._flush_program_warnings(self._pending_program_warnings) + self._pending_program_warnings = [] + + self.worker_mgr = WorkerManager(self.log_message, self.log_box) + self.worker_mgr.set_workers(workout_worker, export_worker) + self.worker_mgr.set_token_update_callback(self._on_tokens_updated) + self.worker_mgr.set_event_callback(self._on_event) + + self._init_chips() + self.root.protocol("WM_DELETE_WINDOW", self.on_closing) + self.root.bind("", self._on_space) + self.root.bind("", self._on_advance_key) + # Pick up newly added ~/KayakFit/programs/*.json without a restart. + self.root.bind("", self._refresh_programs) + self.root.bind("", self._on_root_resize) + self.root.after(1000, self._tick) + # First-run onboarding, then offer to recover an interrupted workout. + self.root.after(500, self._maybe_show_wizard) + self.root.after(800, self._check_recovery) + if self.config_mgr.config_warning: + warning = self.config_mgr.config_warning + self.root.after(100, lambda: self._show_banner(warning, "warn")) + + def _build_layout(self) -> None: + self.root.grid_columnconfigure(0, weight=1) + self.root.grid_rowconfigure(4, weight=1) # dashboard expands + + self._build_toolbar() # row 0 + self._build_chips() # row 1 + self._build_banner() # row 2 (shown on demand) + self._build_program_panel() # row 3 (shown on demand) + self._build_dashboard() # row 4 + self._build_summary_card() # row 5 (shown on demand) + self._build_log_panel() # row 6 + + def _build_toolbar(self) -> None: + bar = ctk.CTkFrame(self.root, corner_radius=0) + bar.grid(row=0, column=0, sticky="ew") + + # A clean app-actions toolbar, a thin divider, then a separate setup bar + # for the per-workout controls (program + weights). Both bands live in + # the header so they always fit the minimum window width. + actions = ctk.CTkFrame(bar, fg_color="transparent") + actions.pack(fill="x", padx=14, pady=(12, 8)) + + # ---- app actions -------------------------------------------------- + self.primary_btn = ctk.CTkButton( + actions, text="Start workout", width=150, command=self._toggle_workout + ) + self.primary_btn.pack(side="left", padx=(0, 8)) + # Remember theme defaults so we can restore them after the red "Stop" state. + self._primary_fg = self.primary_btn.cget("fg_color") + self._primary_hover = self.primary_btn.cget("hover_color") + + # One place for everything about past workouts: browse, save as FIT, + # upload to Strava (replaces the former separate Export/Upload picker). + self.workouts_btn = ctk.CTkButton( + actions, text="Workouts", width=110, command=self.open_history + ) + self.workouts_btn.pack(side="left") + + ctk.CTkButton(actions, text="Settings", width=100, command=self.open_config).pack( + side="right" + ) + ctk.CTkSegmentedButton( + actions, values=["System", "Light", "Dark"], variable=self.appearance_var, + width=180, command=self._set_appearance, + ).pack(side="right", padx=(0, 8)) + + # Recording indicators (shown on demand) sit on the actions row. + self.recording_pill = ctk.CTkLabel( + actions, text="● Recording", fg_color=CHIP_COLORS["warn"][0], + text_color=HR_COLOR, corner_radius=8, padx=10, pady=2, + ) + self.live_label = ctk.CTkLabel( + actions, text="", text_color=SUBTEXT_COLOR, font=ctk.CTkFont(size=12) + ) + self.lap_label = ctk.CTkLabel( + actions, text="", fg_color=CHIP_COLORS["info"][0], + text_color=CHIP_COLORS["info"][1], corner_radius=8, padx=10, pady=2, + font=ctk.CTkFont(size=12), + ) + + # ---- divider ------------------------------------------------------ + ctk.CTkFrame(bar, height=1, fg_color=("#d4d4d4", "#3a3a3a")).pack( + fill="x", padx=14 + ) + + # ---- setup bar: program + weights (centered) ---------------------- + setup = ctk.CTkFrame(bar, fg_color="transparent") + setup.pack(fill="x", padx=14, pady=(8, 12)) + setup.grid_columnconfigure(0, weight=1) + self._setup_container = setup + self._setup_compact = False + controls = ctk.CTkFrame(setup, fg_color="transparent") + controls.grid(row=0, column=0) + row = ctk.CTkFrame(controls, fg_color="transparent") + row.pack(side="left", padx=(0, 10)) + self._program_setup = row + + ctk.CTkLabel( + row, text="Program", text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=12), + ).pack(side="left", padx=(0, 8)) + self.program_menu = ctk.CTkOptionMenu( + row, width=210, variable=self.program_var, + values=[self._free_label, *list(self._program_labels)], + command=self._on_program_selected, + ) + self.program_menu.pack(side="left", padx=(0, 4)) + # Subtle shortcut to the programs folder: drop a JSON file in there and + # it appears in the dropdown (refreshed on window focus). + ctk.CTkButton( + row, text="📂", width=30, height=28, fg_color="transparent", + text_color=SUBTEXT_COLOR, hover_color=("#e8e8e8", "#333333"), + command=self._open_programs_folder, + ).pack(side="left") + + weights = ctk.CTkFrame(controls, fg_color="transparent") + weights.pack(side="left") + self._weights_setup = weights + self.boat_entry = self._weight_stepper( + weights, "Boat (kg)", self.boat_weight_var, 1, 25 + ) + self.person_entry = self._weight_stepper( + weights, "Athlete (kg)", self.person_weight_var, 1, 200 + ) + self.program_preview_var = tk.StringVar(value="Free workout · no planned steps") + self.program_preview_label = ctk.CTkLabel( + setup, textvariable=self.program_preview_var, text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=11), + ) + self.program_preview_label.grid(row=1, column=0, pady=(4, 0)) + self.input_error_var = tk.StringVar(value="") + self.input_error_label = ctk.CTkLabel( + setup, textvariable=self.input_error_var, text_color=HR_COLOR, + font=ctk.CTkFont(size=11, weight="bold"), + ) + self.input_error_label.grid(row=2, column=0) + + def _weight_stepper( + self, parent: Any, label: str, var: tk.StringVar, lo: int, hi: int + ) -> ctk.CTkEntry: + """Build a labelled -/+ stepper around a numeric entry with live validation.""" + box = ctk.CTkFrame(parent, fg_color="transparent") + box.pack(side="left", padx=(0, 10)) + ctk.CTkLabel( + box, text=label, text_color=SUBTEXT_COLOR, font=ctk.CTkFont(size=12) + ).pack(side="left", padx=(0, 8)) + + def step(delta: int) -> None: + try: + current = int(var.get()) + except ValueError: + current = lo + var.set(str(min(hi, max(lo, current + delta)))) + + # A connected -/value/+ unit: same height, minimal gaps, subtle buttons. + ctk.CTkButton( + box, text="-", width=30, height=28, fg_color=("#e4e4e4", "#333333"), + text_color=("#1a1a1a", "#dddddd"), hover_color=("#d4d4d4", "#404040"), + command=lambda: step(-1), + ).pack(side="left") + entry = ctk.CTkEntry(box, textvariable=var, width=44, height=28, justify="center") + entry.pack(side="left", padx=1) + ctk.CTkButton( + box, text="+", width=30, height=28, fg_color=("#e4e4e4", "#333333"), + text_color=("#1a1a1a", "#dddddd"), hover_color=("#d4d4d4", "#404040"), + command=lambda: step(1), + ).pack(side="left") + + self._entry_defaults = getattr( + self, "_entry_defaults", entry.cget("border_color") + ) + + # Only recolor the border when validity actually flips — reconfiguring on + # every keystroke/step forces a redraw and made the steppers "hop". + last_ok: dict[str, bool | None] = {"value": None} + + def validate(*_a: Any) -> None: + try: + value = int(var.get()) + ok = lo <= value <= hi + except ValueError: + ok = False + if ok == last_ok["value"]: + return + last_ok["value"] = ok + entry.configure(border_color="#c0392b" if not ok else self._entry_defaults) + + var.trace_add("write", validate) + validate() + return entry + + def _open_programs_folder(self) -> None: + """Open ~/KayakFit/programs in the OS file manager (best-effort).""" + path = program_mod.programs_dir() + try: + path.mkdir(parents=True, exist_ok=True) + open_in_file_manager(path) + except Exception as e: + self.log_message(f"Could not open programs folder: {e}\n") + + def _build_banner(self) -> None: + self.banner = ctk.CTkFrame(self.root, corner_radius=8) + self.banner_label = ctk.CTkLabel( + self.banner, text="", anchor="w", font=ctk.CTkFont(size=13, weight="bold") + ) + self.banner_label.pack(side="left", padx=12, pady=8) + # Explicit text color: the theme's default button text is near-white, + # which would be invisible on the banner's light background. + ctk.CTkButton( + self.banner, + text="✕", + width=28, + fg_color="transparent", + text_color=("#1a1a1a", "#eaeaea"), + hover=False, + command=self._hide_banner, + ).pack(side="right", padx=8, pady=6) + + def _build_dashboard(self) -> None: + panel = ctk.CTkFrame(self.root, fg_color="transparent") + panel.grid(row=4, column=0, sticky="nsew", padx=14, pady=10) + self._dashboard_panel = panel + + for idx, (key, label, unit, show_stats) in enumerate(TILES): + tile = ctk.CTkFrame(panel, corner_radius=12) + tile.grid( + row=idx // DASHBOARD_COLS, + column=idx % DASHBOARD_COLS, + sticky="nsew", + padx=6, + pady=6, + ) + self.tile_frames[key] = tile + self.tile_defaults[key] = tile.cget("fg_color") + if key == "pace": + # Header is a distance switch rather than a static label. + head = ctk.CTkFrame(tile, fg_color="transparent") + head.pack(anchor="w", fill="x", padx=16, pady=(12, 0)) + ctk.CTkLabel( + head, + text=label, + text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=13), + ).pack(side="left") + ctk.CTkSegmentedButton( + head, + values=["200 m", "500 m", "1000 m"], + variable=self.pace_distance_var, + command=self._on_pace_distance, + height=22, + ).pack(side="right") + else: + ctk.CTkLabel( + tile, + text=label, + text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=13), + ).pack(anchor="w", padx=16, pady=(14, 0)) + value = ctk.CTkLabel( + tile, + textvariable=self.metric_vars[key], + font=ctk.CTkFont(size=44, weight="bold"), + text_color=HR_COLOR if key == "hr" else None, + ) + value.pack(anchor="w", padx=16, pady=(0, 0)) + # Keep refs + original colors so tiles can be dimmed during pauses. + self.tile_value_labels[key] = value + self.tile_value_colors[key] = value.cget("text_color") + if key == "hr": + # Current zone + its bpm range, updated live from the HR reading. + ctk.CTkLabel( + tile, + textvariable=self.hr_zone_var, + text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=12, weight="bold"), + ).pack(anchor="w", padx=16, pady=(0, 0)) + if unit: + ctk.CTkLabel( + tile, + text=unit, + text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=12), + ).pack(anchor="w", padx=16, pady=(0, 0)) + if show_stats: + ctk.CTkLabel( + tile, + textvariable=self.metric_sub_vars[key], + text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=12), + ).pack(anchor="w", padx=16, pady=(2, 12)) + else: + ctk.CTkLabel(tile, text="").pack(anchor="w", padx=16, pady=(2, 12)) + self._layout_dashboard(DASHBOARD_COLS) + + def _layout_dashboard(self, columns: int) -> None: + """Reflow the existing metric tiles for the normal or compact width.""" + panel = self._dashboard_panel + for index in range(DASHBOARD_COLS): + panel.grid_columnconfigure(index, weight=0, uniform="") + for row in range(len(TILES) // 2): + panel.grid_rowconfigure(row, weight=0, uniform="") + rows = (len(TILES) + columns - 1) // columns + for column in range(columns): + panel.grid_columnconfigure(column, weight=1, uniform="tiles") + for row in range(rows): + panel.grid_rowconfigure(row, weight=1, uniform="tiles") + for index, (key, _label, _unit, _stats) in enumerate(TILES): + self.tile_frames[key].grid_configure( + row=index // columns, column=index % columns + ) + + def _build_summary_card(self) -> None: + self.summary_card = ctk.CTkFrame(self.root, corner_radius=12) + # gridded on demand in _show_summary() + self.summary_label = ctk.CTkLabel( + self.summary_card, text="", font=ctk.CTkFont(size=14), justify="left" + ) + self.summary_label.pack(side="left", padx=16, pady=12) + # Dismiss (✕) closes the card without affecting the saved workout. + # Explicit text color: the theme's default button text is near-white, + # which would be invisible on the card's light background. + ctk.CTkButton( + self.summary_card, + text="✕", + width=28, + fg_color="transparent", + text_color=("#1a1a1a", "#eaeaea"), + hover_color=("#d9d9d9", "#3a3a3a"), + command=self._dismiss_summary, + ).pack(side="right", padx=(0, 12), pady=12) + self.strava_btn = ctk.CTkButton( + self.summary_card, + text="View on Strava", + width=140, + command=self._open_strava_activity, + ) + # One-click actions for the just-finished workout (no file picker). + self.upload_btn = ctk.CTkButton( + self.summary_card, + text="Upload to Strava", + width=150, + command=lambda: self._start_export(self.recording.csv_path, upload=True), + ) + self.savefit_btn = ctk.CTkButton( + self.summary_card, + text="Save FIT", + width=110, + command=lambda: self._start_export(self.recording.csv_path, upload=False), + ) + # Opens the detailed summary screen (charts, splits, zones) for the + # workout that was just saved. + self.view_summary_btn = ctk.CTkButton( + self.summary_card, + text="View summary", + width=130, + command=lambda: open_summary_window( + self.root, self.recording.csv_path, self.cfg + ), + ) + # Permanently deletes the owned directory for the just-saved workout. + self.discard_btn = ctk.CTkButton( + self.summary_card, + text="Discard", + width=100, + fg_color=("#c0392b", "#7a2018"), + hover_color=("#a93226", "#5e150f"), + command=self._discard_workout, + ) + + def _build_log_panel(self) -> None: + panel = ctk.CTkFrame(self.root, corner_radius=12) + panel.grid(row=6, column=0, sticky="ew", padx=14, pady=(0, 12)) + panel.grid_columnconfigure(0, weight=1) + + header = ctk.CTkFrame(panel, fg_color="transparent") + header.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 0)) + ctk.CTkLabel(header, text="Activity log", font=ctk.CTkFont(size=13)).pack( + side="left" + ) + self.log_toggle = ctk.CTkButton( + header, text="Hide", width=64, height=24, command=self._toggle_log + ) + self.log_toggle.pack(side="right") + ctk.CTkButton( + header, text="Clear", width=64, height=24, command=self._clear_activity + ).pack(side="right", padx=(0, 6)) + + self.log_container = ctk.CTkFrame(panel, fg_color="transparent") + self.log_container.grid(row=1, column=0, sticky="ew", padx=6, pady=6) + self.log_container.grid_columnconfigure(0, weight=1) + + self.log_box = tk.Text( + self.log_container, + wrap="word", + state="disabled", + height=8, + relief="flat", + borderwidth=0, + padx=10, + pady=6, + ) + self.log_box.grid(row=0, column=0, sticky="ew") + scrollbar = ctk.CTkScrollbar(self.log_container, command=self.log_box.yview) + scrollbar.grid(row=0, column=1, sticky="ns") + self.log_box.configure(yscrollcommand=scrollbar.set) + self._apply_log_theme() + if not self._log_visible: + self.log_container.grid_remove() + self.log_toggle.configure(text="Show") + + def _apply_log_theme(self) -> None: + """Recolor the raw tk.Text log to match the current appearance mode.""" + dark = ctk.get_appearance_mode() == "Dark" + bg, fg = ("#1d1e1e", "#dce4e8") if dark else ("#f7f7f5", "#1a1a1a") + self.log_box.configure(bg=bg, fg=fg, insertbackground=fg) + + def log_message(self, message: str) -> None: + self.log_box.config(state="normal") + self.log_box.insert(tk.END, message) + line_count = int(self.log_box.index("end-1c").split(".")[0]) + if line_count > self.MAX_LOG_LINES: + self.log_box.delete("1.0", f"{line_count - self.MAX_LOG_LINES + 1}.0") + self.log_box.see(tk.END) + self.log_box.config(state="disabled") + + # Make sure failures are never hidden behind a collapsed log. + if ("✗" in message or "ERROR" in message) and not self._log_visible: + self._toggle_log() + + def _clear_activity(self) -> None: + """Clear the activity log and reset the dashboard tiles. + + While a workout is recording, only the log text is cleared so live + averages/maxes are not wiped mid-session; the tiles repopulate from the + next data point regardless. + """ + self.log_box.config(state="normal") + self.log_box.delete("1.0", tk.END) + self.log_box.config(state="disabled") + + if self.recording.active: + self.log_message("Activity log cleared.\n") + return + + for var in self.metric_vars.values(): + var.set("—") + for var in self.metric_sub_vars.values(): + var.set("") + self._stats.clear() + self._live_speed = LiveSpeedTracker() + self._latest_data = {} + self._clear_hr_zone() + self.lap_label.configure(text="") + self.lap_label.pack_forget() + self.recording.paused = False + self.recording.lap = 0 + self._reset_device_chips() + self._dismiss_summary() + + def _toggle_log(self) -> None: + self._log_visible = not self._log_visible + if self._log_visible: + self.log_container.grid() + self.log_toggle.configure(text="Hide") + else: + self.log_container.grid_remove() + self.log_toggle.configure(text="Show") + + def _show_banner(self, text: str, kind: str = "warn") -> None: + fg, _txt = CHIP_COLORS.get(kind, CHIP_COLORS["warn"]) + self.banner.configure(fg_color=fg) + self.banner_label.configure(text=text) + self.banner.grid(row=2, column=0, sticky="ew", padx=14, pady=(2, 0)) + + def _hide_banner(self) -> None: + self.banner.grid_remove() + + def _set_appearance(self, mode: str) -> None: + ctk.set_appearance_mode(mode.lower()) + self.cfg["appearance_mode"] = mode.lower() + # CTk resolves "system" to Light/Dark asynchronously. The activity + # log is a raw tk.Text, so recolor it after that resolution completes. + # CustomTkinter polls the OS theme every 30 ms in system mode. + self.root.after(50, self._apply_log_theme) + + def _on_root_resize(self, event: Any) -> None: + """Apply one compact breakpoint without introducing a layout system.""" + if event.widget is not self.root: + return + compact = event.width < 980 + if compact == self._setup_compact: + return + self._setup_compact = compact + if compact: + self.hr_zone_legend.pack_forget() + else: + self.hr_zone_legend.pack(side="right", padx=(8, 0)) + self._layout_dashboard(2 if compact else DASHBOARD_COLS) + + def open_history(self) -> None: + """Open the Workouts window (browse, save FIT, upload — one place).""" + workouts_dir = Path.home() / "KayakFit" / self.cfg.get("output_dir", "workouts") + workouts_dir.mkdir(parents=True, exist_ok=True) + open_history_window( + self.root, + workouts_dir, + start_export=lambda path, upload: self._start_export(path, upload), + strava_connected=self._strava_connected(), + open_summary=lambda path: open_summary_window(self.root, path, self.cfg), + ) + + def open_config(self) -> None: + # Settings is a persisted-configuration view. Reload first so values + # saved by onboarding or another worker are always reflected when the + # window is created. + self._reload_cfg() + + def on_config_saved() -> None: + self._reload_cfg() + self.boat_weight_var.set(str(self.cfg["boat_weight_default"])) + self.person_weight_var.set(str(self.cfg["person_weight_default"])) + self._init_chips() + self._refresh_hr_legend() + self._sound.set_enabled(get_bool(self.cfg, "sound_cues", default=True)) + # Apply a changed log level without requiring a restart. + Logger.set_level(str(self.cfg.get("log_level", "info"))) + + open_config_window( + self.root, self.cfg, self.config_mgr, on_config_saved, self.log_message + ) + + def on_closing(self) -> None: + # Persist lightweight UI preferences (e.g. pace distance) once, on exit. + with contextlib.suppress(Exception): + latest = self.config_mgr.load_config() + latest["pace_distance"] = self._pace_distance + latest["appearance_mode"] = str( + self.cfg.get("appearance_mode", "system") + ) + latest["activity_log_visible"] = self._log_visible + self.config_mgr.save_config(latest) + # Always release the stay-awake assertion on exit. + self._screen_awake.stop() + if self.worker_mgr and self.worker_mgr.is_running(): + self.worker_mgr.stop_worker() + self.root.after(1000, self._finish_closing) + else: + self._finish_closing() + + def _finish_closing(self, attempts: int = 140) -> None: + # Give recording teardown (CSV fsync + sidecars) and a cancellable + # upload request enough time to reach a durable boundary before the + # daemon worker is abandoned with the process. HTTP calls are bounded + # at 30 s, so 35 s covers the longest normal in-flight operation. + if self.worker_mgr and self.worker_mgr.is_running() and attempts > 0: + self.root.after(250, lambda: self._finish_closing(attempts - 1)) + return + if self.worker_mgr and self.worker_mgr.is_running(): + self.worker_mgr.cleanup() + for action in (self.root.quit, self.root.destroy): + with contextlib.suppress(Exception): + action() diff --git a/gui/metrics_format.py b/gui/metrics_format.py new file mode 100644 index 0000000..b924121 --- /dev/null +++ b/gui/metrics_format.py @@ -0,0 +1,240 @@ +"""Metric formatting helpers for the live dashboard. + +Pure functions with no GUI dependencies, so they can be unit-tested in isolation +and reused by any front end. +""" + +from typing import Any + +from app import stats +from app.power import DEFAULT_PULL_LENGTH_M, estimate_power + + +def fmt_time(seconds: Any) -> str: + """Format seconds as m:ss (or h:mm:ss past an hour).""" + try: + total = int(seconds) + except (TypeError, ValueError): + return "—" + hours, rem = divmod(max(total, 0), 3600) + minutes, secs = divmod(rem, 60) + return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes:02d}:{secs:02d}" + + +def fmt_time_precise(seconds: Any) -> str: + """Format duration exactly to the nearest recorded millisecond.""" + try: + total_ms = max(round(float(seconds) * 1000), 0) + except (TypeError, ValueError): + return "—" + if total_ms % 1000 == 0: + return fmt_time(total_ms // 1000) + hours, remainder_ms = divmod(total_ms, 3_600_000) + minutes, remainder_ms = divmod(remainder_ms, 60_000) + seconds_text = f"{remainder_ms / 1000:06.3f}".rstrip("0").rstrip(".") + return ( + f"{hours}:{minutes:02d}:{seconds_text}" + if hours + else f"{minutes:02d}:{seconds_text}" + ) + + +def fmt_metres(distance_m: float) -> str: + """Format finalized centimetre precision without inventing split values.""" + if abs(distance_m - round(distance_m)) < 0.005: + return f"{round(distance_m):,d} m" + return f"{distance_m:,.2f} m" + + +def hr_zone(hr: Any, max_hr: Any = 185, zones: list[int] | None = None) -> int: + """Classify a heart rate into a training zone 0-4. + + When ``zones`` (five ascending bpm lower bounds) is given it is used directly; + otherwise the bounds are derived from ``max_hr`` (<60% Z1 ... >=90% Z5). + Returns -1 when the heart rate is missing or invalid. + """ + try: + value = float(hr) + except (TypeError, ValueError): + return -1 + bounds = stats.zone_lower_bounds(max_hr=max_hr, zones=zones) + zone = 0 + for i, lower in enumerate(bounds): + if value >= lower: + zone = i + return zone + + +def zone_range( + zone: int, max_hr: Any = 185, zones: list[int] | None = None +) -> tuple[int | None, int | None]: + """Return the (min_bpm, max_bpm) range for a zone (max is None for Z5).""" + if not 0 <= zone <= 4: + return (None, None) + bounds = stats.zone_lower_bounds(max_hr=max_hr, zones=zones) + low = bounds[zone] + high = bounds[zone + 1] - 1 if zone < 4 else None + return (low, high) + + +def format_pace(data: dict[str, Any], distance_m: int) -> str | None: + """Format the ergometer's instantaneous pace for a competition distance. + + Returns an ``m:ss`` string, ``"—"`` when out of range, or None if the field + is absent. + """ + field = f"pace_{int(distance_m)}m_instant__s" + value = data.get(field) + if value is None: + return None + try: + secs = int(value) + except (TypeError, ValueError): + return None + return fmt_time(secs) if 0 < secs < 3600 else "—" + + +def compute_power( + data: dict[str, Any], pull_length_m: Any = DEFAULT_PULL_LENGTH_M +) -> int | None: + """Estimate live power from instantaneous force/cadence channels.""" + return estimate_power( + data.get("pull_force_instant__n"), + data.get("cadence_instant__spm"), + pull_length_m, + ) + + +def metric_value( + key: str, + data: dict[str, Any], + pull_length_m: Any = DEFAULT_PULL_LENGTH_M, +) -> float | None: + """Return one main-window value using instantaneous ergometer channels.""" + field_by_key = { + "speed": "speed_instant__mps", + "stroke": "cadence_instant__spm", + "pull": "pull_force_instant__n", + "hr": "heart_rate__bpm", + } + if key == "power": + power = compute_power(data, pull_length_m) + return float(power) if power is not None else None + field = field_by_key.get(key) + if field is None or data.get(field) is None: + return None + try: + value = float(data[field]) + except (TypeError, ValueError): + return None + return value * 3.6 if key == "speed" else value + + +def format_metrics( + data: dict[str, Any], pull_length_m: Any = DEFAULT_PULL_LENGTH_M +) -> dict[str, str]: + """Map a raw sensor-sample dictionary to display strings keyed by tile id.""" + out: dict[str, str] = {} + if data.get("session_elapsed__s") is not None: + out["time"] = fmt_time(data["session_elapsed__s"]) + if data.get("distance__m") is not None: + out["distance"] = f"{float(data['distance__m']):,.0f}" + for key in ("speed", "stroke", "hr", "pull", "power"): + value = metric_value(key, data, pull_length_m) + if value is not None: + out[key] = f"{value:.1f}" if key == "speed" else f"{round(value)}" + return out + + +# --------------------------------------------------------------------------- # +# Program target adherence # +# --------------------------------------------------------------------------- # +_PACE_DISTANCES = {"pace_200": 200, "pace_500": 500, "pace_1000": 1000} + + +def target_value( + metric: str, + data: dict[str, Any], + max_hr: Any = 185, + zones: list[int] | None = None, + pull_length_m: Any = DEFAULT_PULL_LENGTH_M, +) -> float | None: + """Extract the current value for a target metric from a live data dict. + + Returns None when the value is unavailable. Heart-rate zones are returned + 1-indexed (Z1..Z5) to match the way targets are written. + """ + if metric == "hr_zone": + hr = data.get("heart_rate__bpm") + if hr is None: + return None + zone = hr_zone(hr, max_hr, zones) + return float(zone + 1) if zone >= 0 else None + if metric in _PACE_DISTANCES: + pace_field = f"pace_{_PACE_DISTANCES[metric]}m_instant__s" + value = data.get(pace_field) + try: + secs = float(value) if value is not None else None + except (TypeError, ValueError): + return None + # A zero/huge pace means "not moving"; treat as unavailable. + return secs if secs is not None and 0 < secs < 3600 else None + if metric == "power": + power = compute_power(data, pull_length_m) + return float(power) if power is not None else None + if metric == "spm": + value = data.get("cadence_instant__spm") + return float(value) if value is not None else None + return None + + +def target_status( + metric: str, value: float | None, low: Any, high: Any +) -> str | None: + """Classify a live value against a target range. + + Returns "in", "hard" (harder than intended) or "easy" (easier than + intended), or None if it can't be determined. Pace is inverted — fewer + seconds per split means a *harder* effort. + """ + if value is None or low is None or high is None: + return None + try: + lo, hi = float(low), float(high) + except (TypeError, ValueError): + return None + if metric in _PACE_DISTANCES: # lower seconds == harder + if value < lo: + return "hard" + if value > hi: + return "easy" + return "in" + # hr_zone / power / spm: higher == harder + if value > hi: + return "hard" + if value < lo: + return "easy" + return "in" + + +def target_text( + target: dict[str, Any] | None, max_hr: Any = 185, + zones: list[int] | None = None, +) -> str: + """Human-readable label for a target range (e.g. 'Zone 3', '2:10-2:25 /500m').""" + if not target: + return "" + metric = target.get("metric") + low, high = target.get("low"), target.get("high") + if low is None or high is None: + return "" + if metric == "hr_zone": + return f"Zone {low}" if low == high else f"Zone {low}-{high}" + if metric in _PACE_DISTANCES: + dist = _PACE_DISTANCES[metric] + return f"{fmt_time(low)}-{fmt_time(high)} /{dist}m" + if metric == "power": + return f"{low} W" if low == high else f"{low}-{high} W" + if metric == "spm": + return f"{low} spm" if low == high else f"{low}-{high} spm" + return f"{low}-{high}" diff --git a/gui/program_panel.py b/gui/program_panel.py new file mode 100644 index 0000000..8d703a1 --- /dev/null +++ b/gui/program_panel.py @@ -0,0 +1,446 @@ +"""Live training-program panel and target-adherence indicator. + +``ProgramPanelMixin`` is mixed into ``KayakFitGUI`` (see ``gui.main_gui``). It +defines no ``__init__`` and holds no state of its own; every ``self`` attribute +it uses is created on the main window, and it only ever runs as part of that +class via multiple inheritance. Those shared attributes/methods are declared in +``gui._mixin_base.GuiSharedState``, which this mixin inherits under +``TYPE_CHECKING`` only (so mypy resolves them) — there is no runtime base class. +""" + +import tkinter as tk +from typing import TYPE_CHECKING, Any + +import customtkinter as ctk + +from app import program as program_mod + +from .dashboard_constants import ( + STEP_COLOR_DEFAULT, + STEP_KIND_COLORS, + STEP_KIND_CUES, + SUBTEXT_COLOR, + TARGET_COLORS, + TARGET_LABELS, +) +from .metrics_format import fmt_time, target_status, target_text, target_value + +# Attributes/methods this mixin reads from sibling mixins and KayakFitGUI live on +# the composed class, not here; inherit the shared declarations for type checking +# only (no runtime base change). See gui/_mixin_base.py. +if TYPE_CHECKING: + from ._mixin_base import GuiSharedState as _MixinBase +else: + _MixinBase = object + + +class ProgramPanelMixin(_MixinBase): + """Program panel, step-ending cues, and target-adherence badge.""" + + def _build_program_panel(self) -> None: + """Live training-program panel (gridded on demand at row 3).""" + self.prog_step_var = tk.StringVar(value="") + self.prog_remaining_var = tk.StringVar(value="") + self.prog_next_var = tk.StringVar(value="") + self.prog_count_var = tk.StringVar(value="") + self.prog_target_var = tk.StringVar(value="") + + self.program_panel = ctk.CTkFrame(self.root, corner_radius=10) + self.program_panel.grid_columnconfigure(0, weight=1) + + top = ctk.CTkFrame(self.program_panel, fg_color="transparent") + top.grid(row=0, column=0, sticky="ew", padx=14, pady=(10, 0)) + top.grid_columnconfigure(0, weight=1) + ctk.CTkLabel( + top, + textvariable=self.prog_step_var, + anchor="w", + font=ctk.CTkFont(size=16, weight="bold"), + ).grid(row=0, column=0, sticky="w") + ctk.CTkLabel( + top, + textvariable=self.prog_remaining_var, + anchor="e", + font=ctk.CTkFont(size=22, weight="bold"), + ).grid(row=0, column=1, sticky="e") + + # Progress within the current step (primary — pairs with the countdown). + self.program_step_progress = ctk.CTkProgressBar(self.program_panel, height=14) + self.program_step_progress.set(0) + self.program_step_progress.grid(row=1, column=0, sticky="ew", padx=14, pady=(6, 0)) + + # Overall progress across the whole workout (thin, secondary). + self.program_progress = ctk.CTkProgressBar( + self.program_panel, height=5, progress_color=SUBTEXT_COLOR + ) + self.program_progress.set(0) + self.program_progress.grid(row=2, column=0, sticky="ew", padx=14, pady=(3, 0)) + + # Target-adherence badge (centered; shown only for steps that have a target). + self.prog_target_label = ctk.CTkLabel( + self.program_panel, textvariable=self.prog_target_var, + fg_color="transparent", corner_radius=8, padx=12, pady=2, + font=ctk.CTkFont(size=13, weight="bold"), + ) + self.prog_target_label.grid(row=3, column=0, pady=(6, 0)) + + bottom = ctk.CTkFrame(self.program_panel, fg_color="transparent") + bottom.grid(row=4, column=0, sticky="ew", padx=14, pady=(4, 10)) + bottom.grid_columnconfigure(0, weight=1) + ctk.CTkLabel( + bottom, + textvariable=self.prog_next_var, + anchor="w", + text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=12), + ).grid(row=0, column=0, sticky="w") + ctk.CTkLabel( + bottom, + textvariable=self.prog_count_var, + anchor="e", + text_color=SUBTEXT_COLOR, + font=ctk.CTkFont(size=12), + ).grid(row=0, column=1, sticky="e", padx=(0, 10)) + # Advance the current step — required for manual ("open") steps, and + # skips ahead on timed/distance steps. + self.program_advance_btn = ctk.CTkButton( + bottom, text="Next step →", width=120, height=26, + command=self._advance_program, + ) + self.program_advance_btn.grid(row=0, column=2, sticky="e") + + def _on_program_selected(self, name: str) -> None: + """Show a compact, calculation-free description of the selected plan.""" + program = self._programs.get(self._selected_program_id(name)) + if program is None: + self.program_preview_var.set("Free workout · no planned steps") + return + time_s = sum( + float(step.duration_value or 0) + for step in program.steps + if step.duration_kind == "time" + ) + distance_m = sum( + float(step.duration_value or 0) + for step in program.steps + if step.duration_kind == "distance" + ) + open_count = sum(1 for step in program.steps if step.is_open()) + parts = [f"{len(program)} steps"] + if time_s: + parts.append(fmt_time(time_s)) + if distance_m: + parts.append(f"{distance_m:,.0f} m") + if open_count: + parts.append(f"{open_count} open") + self.program_preview_var.set(" · ".join(parts)) + + def _refresh_programs(self, event: Any = None) -> None: + """Reload the training-program list (e.g. on window focus). + + Lets a program JSON added or edited while the app is open show up in + the dropdown without restarting. Skipped while recording so the menu + never changes mid-workout. + """ + if event is not None and event.widget is not self.root: + return # FocusIn also fires for child widgets; only act for the window + if self.recording.active: + return + try: + warnings: list[str] = [] + programs = {p.identifier: p for p in program_mod.list_programs(warnings)} + except Exception: # never let a bad program file break the UI + return + # Announce load issues only when they change, so a persistently bad + # file doesn't spam the log on every window focus. + sig = tuple(warnings) + if sig != self._program_warn_sig: + self._program_warn_sig = sig + self._flush_program_warnings(warnings) + old_menu = [ + (identifier, program.name) + for identifier, program in self._programs.items() + ] + new_menu = [ + (identifier, program.name) + for identifier, program in programs.items() + ] + if new_menu == old_menu: + self._programs = programs # refresh contents, menu unchanged + self._on_program_selected(self.program_var.get()) + return + selected_id = self._selected_program_id() + self._programs = programs + self._program_labels = self._build_program_labels() + values = [self._free_label, *list(self._program_labels)] + self.program_menu.configure(values=values) + selected_label = next( + ( + label + for label, identifier in self._program_labels.items() + if identifier == selected_id + ), + self._free_label, + ) + if selected_label not in values: + self.program_var.set(self._free_label) + else: + self.program_var.set(selected_label) + self._on_program_selected(self.program_var.get()) + + def _advance_program(self) -> None: + """Advance the running program to the next step (open steps / skip).""" + if self.worker_mgr is not None and self.recording.active: + self.worker_mgr.advance_program() + + def _apply_program(self, ev: dict[str, Any]) -> None: + """Update the live program panel from a program status event.""" + if not self.program_panel.winfo_ismapped() and self.recording.active: + self.program_panel.grid(row=3, column=0, sticky="ew", padx=14, pady=(2, 0)) + + # Once complete, hold the finished state (later ticks carry no step). + if ev.get("done"): + self.prog_step_var.set("Program complete") + self.prog_remaining_var.set("Done") + self.prog_next_var.set("") + self.prog_count_var.set("") + self.program_progress.set(1.0) + self.program_step_progress.set(1.0) + self.program_step_progress.configure(progress_color=STEP_COLOR_DEFAULT) + self._clear_target_indicator() + if ev.get("transition") == "program_complete": + self.log_message("✓ Program complete.\n") + self._sound.cue("complete") + return + + step_kind = (ev.get("step_kind") or "").lower() + is_start = ev.get("transition") == "step_start" + + label = ev.get("step_label") or "—" + kind = step_kind.capitalize() + self.prog_step_var.set( + f"{kind} · {label}" if kind and kind not in label else label + ) + + rem = ev.get("remaining") + kind_r = ev.get("remaining_kind") + self.program_advance_btn.configure( + text="Complete step →" if kind_r not in ("time", "distance") else "Skip step →" + ) + if kind_r == "time" and rem is not None: + self.prog_remaining_var.set(fmt_time(rem)) + elif kind_r == "distance" and rem is not None: + self.prog_remaining_var.set(f"{int(rem)} m") + else: + self.prog_remaining_var.set("—") + + # Heads-up cue as the step nears its end, so you know to stop without + # looking. Fires once per step; open-ended steps have no defined end. + if is_start: + self.program.ending_warned = False + self._maybe_warn_step_ending(kind_r, rem, ev.get("fraction")) + + nxt = ev.get("next_label") + self.prog_next_var.set(f"Next: {nxt}" if nxt else "Last step") + + idx = int(ev.get("step_index", 0)) + total = int(ev.get("total", 0)) + if total: + self.prog_count_var.set(f"Step {idx + 1} / {total}") + # Overall = completed steps + how far through the current one. + frac = ev.get("fraction") + step_frac = float(frac) if isinstance(frac, (int, float)) else 0.0 + self.program_progress.set(min((idx + step_frac) / total, 1.0)) + self.program_step_progress.set(step_frac) + + self.program.target = ev.get("target") + if is_start: + # New step: reset the adherence badge, announce it, color the + # step-progress bar by phase, and play its distinct cue. + self.program.shown_target = None + self.program.pending_target = None + self.program.pending_count = 0 + self._show_initial_target() + self.log_message(f"▶ {self.prog_step_var.get()}\n") + self.program_step_progress.configure( + progress_color=STEP_KIND_COLORS.get(step_kind, STEP_COLOR_DEFAULT) + ) + self._sound.cue(STEP_KIND_CUES.get(step_kind, "phase")) + + # The heads-up aims for a fixed lead time before a step ends, so a distance + # step feels the same whether you're sprinting or cruising. STEP_WARN_METERS + # is only the fallback used when there's no usable speed reading. + STEP_WARN_SECONDS = 3.0 + STEP_WARN_METERS = 10.0 + + def _maybe_warn_step_ending( + self, kind_r: Any, rem: Any, fraction: Any + ) -> None: + """Play a one-shot countdown cue a fixed lead time before a step ends.""" + if self.program.ending_warned or not isinstance(rem, (int, float)): + return + warn_s = self.STEP_WARN_SECONDS + if warn_s <= 0: + return + if kind_r == "time": + threshold = warn_s + elif kind_r == "distance": + # Convert the lead time to metres using current speed, so the cue + # always lands ~warn_s before the finish; fall back to a fixed + # distance when we have no usable speed reading. + try: + speed = float(self._latest_data.get("speed_instant__mps")) + except (TypeError, ValueError): + speed = 0.0 + threshold = ( + speed * warn_s + if speed > 0.3 + else self.STEP_WARN_METERS + ) + else: + return # open-ended step: no defined end to warn about + if rem > threshold: + return + # On a step so short the warning window covers most of it, the countdown + # would land right on top of the step's own start cue — skip it until the + # step is at least half done so the two cues stay distinct. + if isinstance(fraction, (int, float)) and fraction < 0.5: + return + self.program.ending_warned = True + self._sound.cue("countdown") + + def _show_initial_target(self) -> None: + """Show the step's target range (no status yet) or clear the badge.""" + if not self.program.target: + self._clear_target_indicator() + return + rng = target_text( + self.program.target, self.cfg.get("max_hr", 185), self._zones_cfg() + ) + self.prog_target_var.set(f"Target {rng}" if rng else "") + self.prog_target_label.configure( + fg_color="transparent", text_color=SUBTEXT_COLOR + ) + + def _clear_target_indicator(self) -> None: + self.program.target = None + self.program.shown_target = None + self.program.pending_target = None + self.program.pending_count = 0 + self.prog_target_var.set("") + self.prog_target_label.configure(fg_color="transparent") + + def _target_grace(self) -> int: + """Seconds a new adherence status must hold before the badge changes.""" + try: + return max(int(self.cfg.get("target_grace_seconds", 3)), 1) + except (TypeError, ValueError): + return 3 + + def _update_target_indicator(self, data: dict[str, Any]) -> None: + """Compare the live metric to the step target. + + A grace period ensures a brief fluctuation across the boundary doesn't + flip the badge. + """ + target = self.program.target + if not target: + return + metric = target.get("metric") + if not isinstance(metric, str): + return + max_hr = self.cfg.get("max_hr", 185) + zones = self._zones_cfg() + value = target_value( + metric, + data, + max_hr, + zones, + self.cfg.get("pull_length_m"), + ) + raw = target_status(metric, value, target.get("low"), target.get("high")) + if raw is None: + # No reading for this metric (e.g. an hr_zone target with no HRM). + rng = target_text(target, max_hr, zones) + self.prog_target_var.set(f"Target {rng} · no reading" if rng else "") + self.prog_target_label.configure( + fg_color="transparent", text_color=SUBTEXT_COLOR + ) + return + + if raw == self.program.shown_target: + # Back to the shown state — cancel any pending change. + self.program.pending_target = None + self.program.pending_count = 0 + return + + # A different status: only commit it once it has held long enough. + if raw == self.program.pending_target: + self.program.pending_count += 1 + else: + self.program.pending_target = raw + self.program.pending_count = 1 + if self.program.pending_count >= self._target_grace(): + self._commit_target_status(raw, target, max_hr, zones) + self.program.pending_target = None + self.program.pending_count = 0 + + def _commit_target_status( + self, status: str, target: dict[str, Any], max_hr: Any, zones: Any + ) -> None: + """Apply a confirmed adherence status to the badge (and alert on drift).""" + previous = self.program.shown_target + self.program.shown_target = status + rng = target_text(target, max_hr, zones) + self.prog_target_var.set(f"{TARGET_LABELS[status]} · {rng}") + bg, fg = TARGET_COLORS[status] + self.prog_target_label.configure(fg_color=bg, text_color=fg) + # Sound only when newly drifting out of range (the grace period already + # debounces this, so no extra cooldown is needed). + if status in ("hard", "easy") and previous not in ("hard", "easy"): + self._sound.cue("alert") + + def _hide_program_panel(self) -> None: + if self.program_panel.winfo_ismapped(): + self.program_panel.grid_remove() + # Reset so a re-shown panel doesn't flash the previous run's color/fill + # before the first step sets them. + self.program_step_progress.set(0) + self.program_step_progress.configure(progress_color=STEP_COLOR_DEFAULT) + + def _flush_program_warnings(self, warnings: list[str]) -> None: + """Surface a one-line summary of skipped training plans. + + Keeps the activity log uncluttered — the per-file reasons are logged + (and land in ~/KayakFit/logs/kayakfit.log) by app.program itself. + """ + if not warnings: + return + n = len(warnings) + self.log_message( + f"⚠ {n} invalid training plan{'s' if n != 1 else ''} skipped — " + "see the log for details.\n" + ) + + _PROGRAM_LABEL_LIMIT = 34 + + def _build_program_labels(self) -> dict[str, str]: + """Map bounded, unique dropdown labels to stable file identifiers.""" + labels: dict[str, str] = {} + for identifier, program in self._programs.items(): + label = program.name + if len(label) > self._PROGRAM_LABEL_LIMIT: + label = label[: self._PROGRAM_LABEL_LIMIT - 1].rstrip() + "…" + if label in labels: + suffix = 2 + base = label[: self._PROGRAM_LABEL_LIMIT - 4].rstrip(" …") + while f"{base}… {suffix}" in labels: + suffix += 1 + label = f"{base}… {suffix}" + labels[label] = identifier + return labels + + def _selected_program_id(self, label: str | None = None) -> str: + """Resolve a bounded dropdown label to its stable file identifier.""" + selected = str(self.program_var.get()) if label is None else label + return str(self._program_labels.get(selected, selected)) diff --git a/gui/recording_lifecycle.py b/gui/recording_lifecycle.py new file mode 100644 index 0000000..1667d47 --- /dev/null +++ b/gui/recording_lifecycle.py @@ -0,0 +1,599 @@ +"""Workout start/stop lifecycle, live metrics, summary and recovery. + +``RecordingLifecycleMixin`` is mixed into ``KayakFitGUI`` (see ``gui.main_gui``). +It defines no ``__init__`` and holds no state of its own; every ``self`` +attribute it uses is created on the main window. Those shared attributes/methods +are declared in ``gui._mixin_base.GuiSharedState``, which this mixin inherits +under ``TYPE_CHECKING`` only (so mypy resolves them) — no runtime base class. +""" + +import os +import shutil +import stat +import time +import tkinter as tk +from collections.abc import Callable +from pathlib import Path +from tkinter import messagebox +from typing import TYPE_CHECKING, Any + +import customtkinter as ctk + +from app import recovery +from app.events import UiEvent +from app.speed_series import LiveSpeedTracker +from app.workout_paths import WorkoutPaths + +from .dashboard_constants import ( + DANGER_BTN, + DANGER_BTN_HOVER, + DATA_STALE_SECONDS, + HR_COLOR, + HR_STALE_SECONDS, +) +from .metrics_format import fmt_time, format_metrics, format_pace, metric_value +from .setup_wizard import open_setup_wizard + +# See gui/_mixin_base.py: type-checking-only shared surface for the composed GUI. +if TYPE_CHECKING: + from ._mixin_base import GuiSharedState as _MixinBase +else: + _MixinBase = object + + +def _retry_readonly_removal( + func: Callable[..., Any], path: str, exc: BaseException +) -> None: + """Make a read-only path writable and retry its failed removal once.""" + if not isinstance(exc, PermissionError): + raise exc + os.chmod(path, os.stat(path).st_mode | stat.S_IWRITE) + func(path) + + +def _remove_workout_directory(directory: Path) -> None: + """Remove an owned workout directory, including read-only CSV files.""" + shutil.rmtree(directory, onexc=_retry_readonly_removal) + + +class RecordingLifecycleMixin(_MixinBase): + """Event dispatch, metrics, start/stop, stop-confirm, summary, recovery.""" + + def _on_event(self, ev: UiEvent) -> None: + """Handle a structured event from the worker (runs on the GUI thread).""" + kind = ev.get("type") + if kind == "metrics": + self._apply_metrics(ev.get("data", {})) + elif kind == "status": + self._apply_status(ev) + elif kind == "workout_result": + self._set_recording(False) + outcome = ev.get("outcome") + if outcome == "success": + # The thread can still be tearing down; _auto_export waits until + # WorkerManager releases ownership. + self.root.after(300, self._auto_export) + elif outcome in ("failed", "partial"): + messagebox.showerror( + "Workout not completed", + str(ev.get("message") or "The workout did not complete."), + ) + elif kind == "export_result": + self._apply_upload(ev) + self.export.running = False + self._set_export_buttons(enabled=True) + + def _apply_metrics(self, data: dict[str, Any]) -> None: + now = time.monotonic() + self._last_data_ts = now + self._latest_data = data + + # Running card statistics use the same instantaneous channels as their + # main values. Idle packets and zero-duration observations own no card time. + try: + weight = float(data.get("sample_duration__s", 0)) + except (TypeError, ValueError): + weight = 0.0 + if weight <= 0: + weight = 0.0 + # Active time/distance use the shared finalization tracker. Its + # instantaneous speed input is also shown on the live speed card. Idle + # packets advance only the odometer baseline, so totals converge. + self._live_speed.update( + distance_m=data.get("distance__m"), + duration_s=weight, + active=data.get("active_paddling"), + reported_speed_mps=data.get("speed_instant__mps"), + ) + formatted = format_metrics(data, self.cfg.get("pull_length_m")) + formatted["time"] = fmt_time(self._live_speed.active_time_s) + formatted["distance"] = f"{self._live_speed.active_distance_m:,.1f}" + for key, value in formatted.items(): + self.metric_vars[key].set(value) + + try: + elapsed_s = max(float(data.get("session_elapsed__s", 0)), 0.0) + except (TypeError, ValueError): + elapsed_s = self._live_speed.active_time_s + elapsed_s = max(elapsed_s, self._live_speed.active_time_s) + pause_s = max(elapsed_s - self._live_speed.active_time_s, 0.0) + self.metric_sub_vars["time"].set( + f"elapsed {fmt_time(elapsed_s)} · pause {fmt_time(pause_s)}" + ) + + self._refresh_pace() + + if data.get("active_paddling") != 0 and weight > 0: + for key in self.metric_sub_vars: + if key == "time": + continue + raw = self._raw_metric(key, data) + if raw is not None and (key == "speed" or raw > 0): + self._update_stat(key, raw, weight) + + hr = data.get("heart_rate__bpm") + if hr is not None: + self._last_hr_ts = now + self._hr_dimmed = False + bat = f" · {self._hrm_battery}%" if self._hrm_battery is not None else "" + low = self._hrm_battery is not None and self._hrm_battery <= 15 + self._set_chip( + self.hrm_chip, f"HRM · {int(hr)} bpm{bat}", "warn" if low else "ok" + ) + self._set_hr_zone(int(hr)) + + # Live target-adherence badge (only while a program step has a target). + if self.program.target: + self._update_target_indicator(data) + + def _refresh_pace(self) -> None: + """Update the pace tile for the currently selected race distance.""" + pace = format_pace( + self._latest_data, + self._pace_distance, + ) + self.metric_vars["pace"].set(pace or "—") + + def _on_pace_distance(self, choice: str) -> None: + """Switch the pace tile between 200 / 500 / 1000 m. + + Persistence is deferred to app exit so a UI tap never blocks on a + keychain/file write (save_config now touches the OS credential vault). + """ + try: + self._pace_distance = int(str(choice).split()[0]) + except (TypeError, ValueError, IndexError): + return + self.cfg["pace_distance"] = self._pace_distance + self._refresh_pace() + + def _raw_metric(self, key: str, data: dict[str, Any]) -> float | None: + """Extract a card value in display units from its instantaneous channel.""" + return metric_value( + key, + data, + self.cfg.get("pull_length_m"), + ) + + def _update_stat(self, key: str, value: float, weight: float = 1.0) -> None: + """Accumulate avg/max for a tile and refresh its secondary line.""" + st = self._stats.setdefault(key, {"sum": 0.0, "weight": 0.0, "max": None}) + st["sum"] += value * weight + st["weight"] += weight + st["max"] = value if st["max"] is None else max(st["max"], value) + avg = st["sum"] / st["weight"] + self.metric_sub_vars[key].set( + f"avg {self._fmt_stat(key, avg)} · max {self._fmt_stat(key, st['max'])}" + ) + + @staticmethod + def _fmt_stat(key: str, value: float) -> str: + return f"{value:.1f}" if key == "speed" else f"{round(value)}" + + def _update_live_label(self) -> None: + """Refresh the live indicator, giving the paused state priority.""" + if not self.recording.active: + return + if self.recording.paused: + self.live_label.configure(text="⏸ Paused", text_color=HR_COLOR) + else: + self.live_label.configure(text="● live", text_color=("#1d7a4f", "#7ee0ab")) + + def _tick(self) -> None: + """Once-a-second housekeeping: liveness indicator and HR staleness.""" + try: + if self.recording.active and self._last_data_ts is not None: + gap = time.monotonic() - self._last_data_ts + if gap > DATA_STALE_SECONDS: + self.live_label.configure( + text=f"⚠ no data for {int(gap)}s", text_color=HR_COLOR + ) + else: + # Paused state takes priority over the generic "live" tag. + self._update_live_label() + + if ( + self.recording.active + and not self._hr_dimmed + and self._last_hr_ts is not None + and (time.monotonic() - self._last_hr_ts) > HR_STALE_SECONDS + ): + self._hr_dimmed = True + self._clear_hr_zone() + self.metric_vars["hr"].set("—") + self._set_chip(self.hrm_chip, "HRM · no signal", "muted") + finally: + self.root.after(1000, self._tick) + + def _focused_widget(self) -> Any | None: + """Return the focused widget, or None. + + ``focus_get()`` raises KeyError when focus is in a dropdown's popdown + window (a Tk widget with no Python wrapper — happens with option menus, + especially on Windows), so it must be guarded. + """ + try: + return self.root.focus_get() + except (KeyError, tk.TclError): + return None + + def _on_space(self, event: Any) -> str | None: + """Spacebar toggles the workout, unless typing or focused on a button. + + A button receiving space already fires its own command, so toggling as + well would double-act (e.g. stop then immediately restart). + """ + widget = self._focused_widget() + if isinstance(widget, tk.Entry): + return None + node = widget + while node is not None: + if isinstance(node, ctk.CTkButton): + return None + node = getattr(node, "master", None) + self._toggle_workout() + return "break" + + def _on_advance_key(self, event: Any) -> str | None: + """Right-arrow advances the current program step (not while typing).""" + if isinstance(self._focused_widget(), tk.Entry): + return None + if self.recording.active and self.program_panel.winfo_ismapped(): + self._advance_program() + return "break" + return None + + def _parse_weights(self) -> tuple[int, int] | None: + try: + boat = int(self.boat_weight_var.get()) + person = int(self.person_weight_var.get()) + except ValueError: + self.input_error_var.set("Boat and person weights must be whole numbers.") + self.boat_entry.focus_set() + return None + if not (1 <= boat <= 25): + self.input_error_var.set("Boat weight must be between 1 and 25 kg.") + self.boat_entry.focus_set() + return None + if not (1 <= person <= 200): + self.input_error_var.set("Person weight must be between 1 and 200 kg.") + self.person_entry.focus_set() + return None + self.input_error_var.set("") + return boat, person + + def _toggle_workout(self) -> None: + if self.recording.active: + # Two-tap confirm: the first tap arms, a second within the window + # actually stops. Prevents a stray click/space ending the workout. + if not self.recording.stop_armed: + self._arm_stop() + else: + self._disarm_stop() + if self.worker_mgr is not None: + self.worker_mgr.stop_worker() + return + + weights = self._parse_weights() + if weights is None: + return + boat, person = weights + + # ConfigManager already normalizes any "Name (address)" label on load. + erg_mac = str(self.cfg.get("ergometer_mac", "")).strip() + if not erg_mac: + self.log_message( + "No ergometer configured. Open Settings to scan and select one.\n" + ) + if messagebox.askyesno( + "No ergometer configured", + "No ergometer is set up yet. Open Settings to scan for and select " + "your ergometer now?", + ): + self.open_config() + return + + selected_label = self.program_var.get() + selected = self._selected_program_id(selected_label) + program = self._programs.get(selected) # None for "Free workout" + # A program workout reports laps step-based afterwards (summary + FIT), + # so its live lap chip is suppressed to keep the two consistent. + self.program.active = program is not None + if program is not None: + self.log_message(f"Program: {program.name} ({len(program)} steps).\n") + elif selected_label != self._free_label: + # Selected plan's JSON was removed and the dropdown hasn't + # refreshed yet: fall back explicitly rather than silently. + self.log_message( + f"Program '{selected}' no longer exists — starting a free workout.\n" + ) + + self._reset_dashboard() + # Only flip to the recording state once the worker actually started; + # start_worker refuses while the previous worker thread is still + # tearing down, and a refused start must not wedge the UI. + if self.worker_mgr is not None and self.worker_mgr.start_worker( + worker_type="workout", + args={ + "boat_weight": boat, + "person_weight": person, + "ergometer_mac": erg_mac, + "program": program, + }, + ): + self._set_recording(True) + + def _set_recording(self, recording: bool) -> None: + self.recording.active = recording + if recording: + self._screen_awake.start() + self.primary_btn.configure( + text="Stop & save", fg_color=DANGER_BTN, hover_color=DANGER_BTN_HOVER + ) + self.workouts_btn.configure(state="disabled") + self.program_menu.configure(state="disabled") + self.recording_pill.pack(side="left", padx=(0, 6)) + self.live_label.configure(text="● live", text_color=("#1d7a4f", "#7ee0ab")) + self.live_label.pack(side="left", padx=(0, 12)) + else: + self._screen_awake.stop() + self._disarm_stop() + self.primary_btn.configure( + text="Start workout", + fg_color=self._primary_fg, + hover_color=self._primary_hover, + ) + self.workouts_btn.configure(state="normal") + self.program_menu.configure(state="normal") + self.recording_pill.pack_forget() + self.live_label.pack_forget() + self.lap_label.pack_forget() + self.program.active = False + self.recording.paused = False + self._restore_tile_brightness() + self._reset_device_chips() + self._clear_hr_zone() + self._clear_target_indicator() + self._hide_program_panel() + if self.recording.persistence_error is None: + self._hide_banner() + + # Seconds the "Stop" button stays armed awaiting a confirming second tap. + STOP_CONFIRM_SECONDS = 3 + + def _arm_stop(self) -> None: + """Arm the stop button; it reverts if not confirmed within the window.""" + self.recording.stop_armed = True + self.primary_btn.configure(text="Tap again to stop") + self.log_message("Tap Stop again (or press space) to end the workout.\n") + if self.recording.stop_timer_id is not None: + self.root.after_cancel(self.recording.stop_timer_id) + self.recording.stop_timer_id = self.root.after( + self.STOP_CONFIRM_SECONDS * 1000, self._disarm_stop + ) + + def _disarm_stop(self) -> None: + """Cancel a pending stop confirmation and restore the button label.""" + if self.recording.stop_timer_id is not None: + self.root.after_cancel(self.recording.stop_timer_id) + self.recording.stop_timer_id = None + if self.recording.stop_armed: + self.recording.stop_armed = False + if self.recording.active: + self.primary_btn.configure(text="Stop & save") + + def _reset_dashboard(self) -> None: + for var in self.metric_vars.values(): + var.set("—") + for var in self.metric_sub_vars.values(): + var.set("") + self._stats.clear() + self._live_speed = LiveSpeedTracker() + self._latest_data = {} + self._clear_hr_zone() + self._last_data_ts = None + self._last_hr_ts = None + self._hr_dimmed = False + self.recording.paused = False + self.recording.lap = 0 + self.program.ending_warned = False + self._hrm_battery = None + self.lap_label.configure(text="") + self.lap_label.pack_forget() + self._restore_tile_brightness() + self._clear_target_indicator() + self._hide_banner() + if self.summary_card.winfo_ismapped(): + self.summary_card.grid_remove() + self.strava_btn.pack_forget() + self.upload_btn.pack_forget() + self.savefit_btn.pack_forget() + self.view_summary_btn.pack_forget() + self.discard_btn.pack_forget() + self.recording.reset_workout() + self.export.strava_activity_id = None + + def _show_summary(self, ev: dict[str, Any]) -> None: + dist = ev.get("distance_m", 0) + active = fmt_time(ev.get("active_time_s", 0)) + elapsed = fmt_time(ev.get("elapsed_time_s", 0)) + points = ev.get("record_count", 0) + self.recording.csv_path = ev.get("csv_path") + self.recording.points = points + self.recording.incomplete = bool(ev.get("incomplete")) + outcome = ( + "Partial workout retained" if self.recording.incomplete else "Workout saved" + ) + self.summary_label.configure( + text=( + f"{outcome} · {dist:,.0f} m · active {active}" + f" · elapsed {elapsed} · {points} data points" + ) + ) + # Offer one-click actions for this workout when we have a data file. + # Uniform right-pad on every button (they pack right-to-left) keeps the + # gaps between them even. + if self.recording.csv_path and points > 0: + self.savefit_btn.pack(side="right", padx=(0, 12), pady=12) + if self._strava_connected() and not self.recording.incomplete: + self.upload_btn.pack(side="right", padx=(0, 12), pady=12) + self.view_summary_btn.pack(side="right", padx=(0, 12), pady=12) + self.discard_btn.pack(side="right", padx=(0, 12), pady=12) + self.summary_card.grid(row=5, column=0, sticky="ew", padx=14, pady=(0, 6)) + + def _dismiss_summary(self) -> None: + """Hide the post-workout summary card and its action buttons. + + The saved CSV is untouched; the workout can still be re-exported later + from the Workouts window. + """ + if self.summary_card.winfo_ismapped(): + self.summary_card.grid_remove() + self.strava_btn.pack_forget() + self.upload_btn.pack_forget() + self.savefit_btn.pack_forget() + self.view_summary_btn.pack_forget() + self.discard_btn.pack_forget() + # The user has seen and closed the summary; the CSV remains in Workouts, + # so this workout no longer needs the crash-recovery prompt. + try: + recovery.clear_active() + except OSError as exc: + self._show_banner(f"Could not clear workout recovery state — {exc}", "warn") + + def _discard_workout(self) -> None: + """Permanently delete the just-finished workout after confirmation. + + Removes the owned workout directory so no associated artifact reaches + the Workouts window, then clears the summary card and recovery marker. + """ + if self.export.running or (self.worker_mgr and self.worker_mgr.is_running()): + self._show_banner("Wait for the current export or upload to finish.", "warn") + return + csv_path = self.recording.csv_path + if not csv_path: + self._dismiss_summary() + return + if not messagebox.askyesno( + "Discard workout", + "Delete this workout permanently?\n\n" + "All files for this workout will be removed and " + "cannot be recovered.", + ): + return + removed = False + try: + paths = WorkoutPaths.from_csv(csv_path) + workouts_root = ( + Path.home() / "KayakFit" / self.cfg.get("output_dir", "workouts") + ) + if not paths.is_owned_by(workouts_root): + raise ValueError("refusing to delete a path outside the workout library") + _remove_workout_directory(paths.directory) + removed = True + except Exception as e: # never let a failed delete crash the UI + self._show_banner(f"Could not discard workout — {e}", "warn") + return + try: + recovery.clear_active() + except OSError as exc: + self._show_banner(f"Could not clear workout recovery state — {exc}", "warn") + if self.summary_card.winfo_ismapped(): + self.summary_card.grid_remove() + self.strava_btn.pack_forget() + self.upload_btn.pack_forget() + self.savefit_btn.pack_forget() + self.view_summary_btn.pack_forget() + self.discard_btn.pack_forget() + self.recording.csv_path = None + self.export.strava_activity_id = None + self.log_message( + "🗑 Workout discarded.\n" if removed else "Workout already removed.\n" + ) + + def _maybe_show_wizard(self) -> None: + """Show the first-run setup wizard once (until it's completed/skipped).""" + if self.cfg.get("wizard_seen"): + return + + def on_complete() -> None: + self._reload_cfg() + self._init_chips() + self._refresh_hr_legend() + + try: + open_setup_wizard( + self.root, + self.cfg, + self.config_mgr, + self.log_message, + on_complete=on_complete, + open_settings=self.open_config, + ) + except Exception as e: # never let onboarding block startup + self.log_message(f"Setup wizard could not open: {e}\n") + + def _check_recovery(self) -> None: + """On startup, offer to export a workout interrupted before it finished.""" + try: + info = recovery.pending_recovery() + except OSError as exc: # never let recovery bookkeeping block startup + self._show_banner(f"Could not inspect workout recovery state — {exc}", "warn") + info = None + if not info or self.recording.active: + return + + csv_path = info.get("csv_path") + if not csv_path or not os.path.exists(csv_path): + try: + recovery.clear_active() + except OSError as exc: + self._show_banner( + f"Could not clear workout recovery state — {exc}", "warn" + ) + return + + started = info.get("started_at", "") + when = started.replace("T", " ") if isinstance(started, str) else "" + detail = f" started {when}" if when else "" + if not messagebox.askyesno( + "Recover unfinished workout", + f"A workout{detail} was interrupted before it finished exporting.\n\n" + "Convert it to a FIT file now?", + ): + try: + recovery.clear_active() + except OSError as exc: + self._show_banner( + f"Could not clear workout recovery state — {exc}", "warn" + ) + return + + upload = False + if self._strava_connected(): + upload = messagebox.askyesno( + "Upload to Strava", "Also upload this recovered workout to Strava?" + ) + self.recording.csv_path = csv_path + self.log_message(f"Recovering interrupted workout: {csv_path}\n") + self._start_export(csv_path, upload=upload, announce=True) diff --git a/gui/setup_wizard.py b/gui/setup_wizard.py new file mode 100644 index 0000000..7c6486c --- /dev/null +++ b/gui/setup_wizard.py @@ -0,0 +1,280 @@ +"""First-run setup wizard. + +A lightweight, one-window onboarding flow shown the first time KayakFit runs: +scan for Bluetooth devices, pick the ergometer (and optionally a heart-rate +monitor), and point the user to Strava setup. It reuses the same background BLE +scanner as the Settings window and stays decoupled from the worker plumbing. +""" + +import contextlib +import threading +from collections.abc import Callable +from tkinter import messagebox +from typing import Any + +import customtkinter as ctk + +from gui.config_manager import ConfigError +from gui.device_scanner import scan_devices +from gui.window_utils import center_over_parent + +LogFn = Callable[[str], None] + +_ERG_PLACEHOLDER = "— scan to find devices —" +_HRM_NONE = "None (no heart-rate monitor)" + +# Muted hint text, matching the main dashboard's subtext color (light, dark). +_MUTED = ("#5f5f5f", "#9a9a9a") + +# Secondary (outlined) button style: the theme's default button text is +# near-white, which would be invisible on a transparent button in light mode. +_SECONDARY_BTN = { + "fg_color": "transparent", + "border_width": 1, + "border_color": ("#b0b0b0", "#565656"), + "text_color": ("#1a1a1a", "#dddddd"), + "hover_color": ("#e8e8e8", "#333333"), +} + + +def open_setup_wizard( + root: ctk.CTk, + cfg: dict[str, Any], + config_mgr: Any, + log: LogFn, + on_complete: Callable[[], None] | None = None, + open_settings: Callable[[], None] | None = None, +) -> None: + """Open the first-run setup wizard. + + Args: + root: Parent window. + cfg: Current configuration dictionary. + config_mgr: ConfigManager instance (used to persist the result). + log: Callback to append a line to the main log. + on_complete: Called after the wizard finishes or is skipped. + open_settings: Optional callback to open the Settings window (Strava). + """ + win = ctk.CTkToplevel(master=root) + win.title("Welcome to KayakFit") + center_over_parent(win, root, 520, 460) + win.minsize(480, 420) + win.transient(root) + win.after(200, win.grab_set) + + scan_stop_event = threading.Event() + erg_map: dict[str, tuple[str, str]] = {} + hrm_map: dict[str, tuple[str, str]] = {} + + erg_var = ctk.StringVar(value=_ERG_PLACEHOLDER) + erg_name_var = ctk.StringVar(value=str(cfg.get("ergometer_name", ""))) + hrm_var = ctk.StringVar(value=_HRM_NONE) + strava_var = ctk.BooleanVar(value=False) + + container = ctk.CTkFrame(win, fg_color="transparent") + container.pack(fill="both", expand=True, padx=20, pady=20) + + def _finish(skipped: bool) -> None: + scan_stop_event.set() + try: + if not skipped: + _save_devices( + cfg, + erg_map, + erg_var.get(), + hrm_map, + hrm_var.get(), + ) + cfg["wizard_seen"] = True + config_mgr.save_config(cfg) + except (ConfigError, OSError, ValueError) as exc: + messagebox.showerror("Could not save devices", str(exc), parent=win) + return + want_strava = strava_var.get() + with contextlib.suppress(Exception): + win.destroy() + if on_complete: + on_complete() + if not skipped and want_strava and open_settings: + open_settings() + + win.protocol("WM_DELETE_WINDOW", lambda: _finish(skipped=True)) + + # ---- Step 2 (devices) ------------------------------------------------- + def show_devices_step() -> None: + for child in container.winfo_children(): + child.destroy() + + ctk.CTkLabel( + container, text="Choose your devices", + font=ctk.CTkFont(size=20, weight="bold"), + ).pack(anchor="w", pady=(0, 4)) + ctk.CTkLabel( + container, + text=( + "Pick your ergometer below. A heart-rate monitor is optional." + if erg_map + else f'No ergometer named "{erg_name_var.get()}" was found. ' + "Choose Rescan to check or correct the device name." + ), + text_color=_MUTED, wraplength=460, justify="left", + ).pack(anchor="w", pady=(0, 12)) + + ctk.CTkLabel(container, text="Ergometer").pack(anchor="w") + erg_menu = ctk.CTkOptionMenu( + container, values=list(erg_map) or [_ERG_PLACEHOLDER], variable=erg_var, + ) + erg_menu.pack(fill="x", pady=(0, 10)) + + ctk.CTkLabel(container, text="Heart-rate monitor (optional)").pack(anchor="w") + hrm_menu = ctk.CTkOptionMenu( + container, values=[_HRM_NONE, *list(hrm_map)], variable=hrm_var, + ) + hrm_menu.pack(fill="x", pady=(0, 10)) + + ctk.CTkCheckBox( + container, text="Set up Strava after finishing", variable=strava_var, + ).pack(anchor="w", pady=(4, 0)) + + row = ctk.CTkFrame(container, fg_color="transparent") + row.pack(side="bottom", fill="x", pady=(16, 0)) + + def _rescan() -> None: + show_welcome_step() + + ctk.CTkButton( + row, text="Rescan", width=100, **_SECONDARY_BTN, + command=_rescan, + ).pack(side="left") + ctk.CTkButton(row, text="Finish", command=lambda: _finish(skipped=False)).pack( + side="right" + ) + + def handle_scan_result(result: dict[str, Any]) -> None: + if result.get("cancelled"): + return + if result.get("error"): + log(f"Scan error: {result['error']}\n") + log("Make sure Bluetooth is on and devices are nearby.\n") + show_welcome_step(error="Bluetooth scan failed. Check Bluetooth and try again.") + return + + erg_devices: list[tuple[str, str]] = result.get("erg_devices", []) + hrm_devices: list[tuple[str, str]] = result.get("hrm_devices", []) + erg_map.clear() + hrm_map.clear() + for name, addr in erg_devices: + erg_map[f"{name} ({addr})"] = (name, addr) + for name, addr in hrm_devices: + hrm_map[f"{name} ({addr})"] = (name, addr) + + if erg_map: + erg_var.set(next(iter(erg_map))) + else: + erg_var.set(_ERG_PLACEHOLDER) + hrm_var.set(next(iter(hrm_map)) if hrm_map else _HRM_NONE) + log(f"Scan complete: {len(erg_devices)} ergometer(s), {len(hrm_devices)} HRM(s).\n") + show_devices_step() + + def start_scan() -> None: + ergometer_name = erg_name_var.get().strip() + if not ergometer_name: + show_welcome_step(error="Enter the device name printed on the ergometer.") + return + scan_stop_event.clear() + show_welcome_step(scanning=True) + scan_devices( + erg_pattern=ergometer_name, + on_log=log, + on_result=handle_scan_result, + schedule=lambda fn: win.after(0, fn), + stop_event=scan_stop_event, + ) + + # ---- Step 1 (welcome) ------------------------------------------------- + def show_welcome_step(scanning: bool = False, error: str | None = None) -> None: + for child in container.winfo_children(): + child.destroy() + ctk.CTkLabel( + container, text="Welcome to KayakFit", + font=ctk.CTkFont(size=22, weight="bold"), + ).pack(anchor="w", pady=(0, 6)) + ctk.CTkLabel( + container, + text=( + "Let's connect your KayakFirst ergometer so you can start logging " + "workouts. Turn the ergometer on and keep it nearby. Enter the " + "device name printed in large letters on the ergometer." + ), + text_color=_MUTED, wraplength=460, justify="left", + ).pack(anchor="w", pady=(0, 14)) + + ctk.CTkLabel(container, text="Ergometer device name").pack(anchor="w") + name_entry = ctk.CTkEntry( + container, + textvariable=erg_name_var, + placeholder_text="Name printed on the ergometer", + ) + name_entry.pack(fill="x", pady=(4, 6)) + name_entry.configure(state="disabled" if scanning else "normal") + + if error: + ctk.CTkLabel( + container, + text=error, + text_color=("#a02c20", "#ff8a80"), + wraplength=460, + justify="left", + ).pack(anchor="w", pady=(2, 4)) + + if scanning: + status = ctk.CTkLabel( + container, text="Scanning for Bluetooth devices (~10s)…", + text_color=_MUTED, + ) + status.pack(anchor="w", pady=(6, 0)) + bar = ctk.CTkProgressBar(container, mode="indeterminate") + bar.pack(fill="x", pady=(8, 0)) + bar.start() + + row = ctk.CTkFrame(container, fg_color="transparent") + row.pack(side="bottom", fill="x", pady=(16, 0)) + ctk.CTkButton( + row, text="Skip for now", width=110, **_SECONDARY_BTN, + command=lambda: _finish(skipped=True), + ).pack(side="left") + if not scanning: + ctk.CTkButton(row, text="Scan for devices", command=start_scan).pack( + side="right" + ) + name_entry.bind("", lambda _event: start_scan()) + name_entry.focus_set() + + show_welcome_step() + + +def _save_devices( + cfg: dict[str, Any], + erg_map: dict[str, tuple[str, str]], + erg_label: str, + hrm_map: dict[str, tuple[str, str]], + hrm_label: str, +) -> None: + """Persist the chosen ergometer / HRM into the config.""" + if erg_label not in erg_map: + raise ValueError("Select an ergometer before finishing setup.") + erg_name, erg_address = erg_map[erg_label] + cfg["ergometer_name"] = str(erg_name) + cfg["ergometer_mac"] = str(erg_address) + + if hrm_label in hrm_map: + name, addr = hrm_map[hrm_label] + cfg["hrm_name"] = str(name) + cfg["hrm_mac"] = str(addr) + elif hrm_label == _HRM_NONE: + # Explicitly chose no strap: clear any previously stored address so + # the session doesn't keep trying to connect to it. + cfg["hrm_name"] = "" + cfg["hrm_mac"] = "" + else: + raise ValueError("Select a heart-rate monitor or choose None.") diff --git a/gui/summary_window.py b/gui/summary_window.py new file mode 100644 index 0000000..bb317f4 --- /dev/null +++ b/gui/summary_window.py @@ -0,0 +1,1175 @@ +"""Post-workout summary window. + +Shows one recorded workout in detail: headline stats, speed / heart-rate / +power charts over time, time in each heart-rate zone, and a per-lap splits +table. Laps match the FIT export (same segmentation rules), while Strava may +recalculate or present imported workouts differently. + +Charts are drawn on plain ``tkinter.Canvas`` — no plotting library, so the +packaged app stays small. All number crunching lives in :mod:`app.summary`; +this module is UI only. +""" + +import bisect +import math +import threading +import tkinter as tk +from collections.abc import Callable, Sequence +from pathlib import Path +from tkinter import messagebox +from typing import Any, Literal + +import customtkinter as ctk + +from app.read_csv import CsvReader +from app.segmentation import load_program_steps +from app.summary import WorkoutSummary, compute_summary +from app.workout_metadata import load_workout_metadata + +from .metrics_format import fmt_metres, fmt_time, fmt_time_precise +from .window_utils import center_over_parent + +# (light, dark) theme colors, matching the main dashboard's palette. +_BG = ("#f3f3f3", "#1f1f1f") +_GRID = ("#e0e0e0", "#333333") +_TEXT = ("#5f5f5f", "#9a9a9a") +_SPEED = ("#1f6fd0", "#6aa8e8") +_HR = ("#c0392b", "#f0a098") +_POWER = ("#9a6a06", "#f0c068") +_CADENCE = ("#7d4fb0", "#b491dd") +_PULL = ("#1e8a6e", "#6fcbb2") +_STEP_FILLS = { + "warmup": ("#e8f3ea", "#25352a"), + "work": ("#e7eff9", "#243140"), + "rest": ("#eeeeee", "#2a2a2a"), + "cooldown": ("#eee9f5", "#302a38"), +} +_STEP_DEFAULT_FILL = ("#f0f0f0", "#292929") +# Solid zone colors for the time-in-zone bar (light, dark), Z1..Z5. +_ZONE_COLORS = [ + ("#b8b8b8", "#5a5a5a"), + ("#7fb2e8", "#3f6ea8"), + ("#77cf9d", "#2f8f5e"), + ("#f0c068", "#a87f2f"), + ("#e8867a", "#a84438"), +] +_ZONE_NAMES = ["Z1 Recovery", "Z2 Easy", "Z3 Aerobic", "Z4 Threshold", "Z5 Max"] +_NO_HR_COLOR = ("#d6d6d6", "#454545") +# Text drawn on top of the zone colors: dark on the light variants, light on +# the dark variants, so the labels stay readable in both modes. +_ZONE_TEXT = ("#1a1a1a", "#f2f2f2") + +MPS_TO_KMH = 3.6 +_SUMMARY_LOADS: dict[str, Any] = {} +# Let the summary window paint its headline metrics before constructing the +# Canvas charts, which are the most expensive widgets for a long recording. +_DETAIL_RENDER_DELAY_MS = 25 +# A per-lap row owns 8-10 widgets. Batching avoids a long UI-thread pause for +# workouts with many autopause laps while still filling an ordinary table at a +# natural pace. +_INITIAL_SPLIT_ROWS = 24 +_SPLIT_RENDER_BATCH_ROWS = 24 +_SPLIT_RENDER_DELAY_MS = 16 +_POWER_TOOLTIP = ( + "Estimated power uses the same instantaneous pull force and stroke rate " + "shown in the charts." +) + + +def _theme(pair: tuple[str, str]) -> str: + """Pick the light/dark variant of a color pair for the current mode.""" + return pair[1] if ctk.get_appearance_mode() == "Dark" else pair[0] + + +def _kmh(values: list[float | None]) -> list[float | None]: + return [v * MPS_TO_KMH if v is not None else None for v in values] + + +def _downsample_indices( + xs: Sequence[float | None], + ys: Sequence[float | None], + breaks: Sequence[bool], + width: int, +) -> list[int]: + """Keep visual extrema and boundaries while bounding Canvas point count.""" + if len(xs) <= max(width * 2, 200): + return list(range(len(xs))) + valid_x = [value for value in xs if value is not None] + if not valid_x: + return [] + x_min, x_max = min(valid_x), max(valid_x) + span = max(x_max - x_min, 1.0) + buckets: dict[int, list[int]] = {} + forced: set[int] = {0, len(xs) - 1} + for index, (x_value, y_value) in enumerate(zip(xs, ys, strict=False)): + if (index < len(breaks) and breaks[index]) or x_value is None or y_value is None: + forced.update((max(index - 1, 0), index, min(index + 1, len(xs) - 1))) + continue + pixel = min(int((x_value - x_min) / span * max(width - 1, 1)), width - 1) + buckets.setdefault(pixel, []).append(index) + kept = set(forced) + for indices in buckets.values(): + kept.update((indices[0], indices[-1])) + values: list[float] = [] + for index in indices: + value = ys[index] + if value is not None: + values.append(value) + kept.add(indices[min(range(len(values)), key=values.__getitem__)]) + kept.add(indices[max(range(len(values)), key=values.__getitem__)]) + return sorted(kept) + + +def _nice_step(span: float, target_ticks: int = 4) -> float: + """A 'nice' value-axis step (1/2/2.5/5 x 10^k) giving ~target_ticks lines.""" + raw = max(span, 1e-9) / target_ticks + magnitude = 10.0 ** math.floor(math.log10(raw)) + for mult in (1.0, 2.0, 2.5, 5.0, 10.0): + if raw <= mult * magnitude: + return mult * magnitude + return 10.0 * magnitude + + +def _axis_bounds( + values: list[float | None], + floor: float | None = None, + ceiling: float | None = None, + authoritative_max: float | None = None, + margin_frac: float = 0.08, +) -> tuple[float, float]: + """A value-axis ``(lo, hi)`` that is robust to sparse outliers. + + A single dropped-sample spike (e.g. a garbage 300 bpm reading) would + otherwise stretch the whole panel and flatten the real trace. The range is + taken from the 1st/99th percentiles when there are enough samples, but only + when the true extreme sits well beyond them, so a genuine sprint peak is + still shown in full. Optional physical ``floor``/``ceiling`` clamps (e.g. + speed <= 25 km/h, HR <= 250 bpm) act as a final backstop for metrics with a + known plausible range. + + Args: + values: The series values; ``None`` entries are ignored. + floor: Lowest plausible value; the axis never starts below it. + ceiling: Highest plausible value; the axis never ends above it. + authoritative_max: Raw aggregate maximum that must remain inside the + visible axis even when percentile outlier handling is active. + margin_frac: Fraction of the range added as headroom top and bottom. + + Returns: + The ``(lo, hi)`` axis bounds, margin included. + """ + vals = sorted(v for v in values if v is not None) + if not vals: + return 0.0, 1.0 + lo, hi = vals[0], vals[-1] + n = len(vals) + if n >= 12: + p_hi = vals[math.ceil(0.99 * (n - 1))] + p_lo = vals[int(0.01 * (n - 1))] + if hi > p_hi * 1.25: # isolated high spike, not a genuine peak + hi = p_hi + if p_lo > 0 and lo < p_lo * 0.8: # isolated low dropout + lo = p_lo + if ceiling is not None: + hi = min(hi, ceiling) + if authoritative_max is not None and math.isfinite(authoritative_max): + hi = max(hi, authoritative_max) + if floor is not None: + lo = max(lo, floor) + if hi - lo < 1e-9: + lo, hi = lo - 1.0, hi + 1.0 + margin = (hi - lo) * margin_frac + lo_m, hi_m = lo - margin, hi + margin + if floor is not None: # keep the natural baseline (0 speed, 0 bpm) in view + lo_m = max(lo_m, floor) + if ceiling is not None and ( + authoritative_max is None or authoritative_max <= ceiling + ): + hi_m = min(hi_m, ceiling) + return lo_m, hi_m + + +# Candidate time-axis steps (seconds): sub-minute up to hours. +_TIME_STEPS = [10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200] + + +def _time_step(t_max: float, target_ticks: int = 5) -> int: + for step in _TIME_STEPS: + if t_max / step <= target_ticks: + return step + return _TIME_STEPS[-1] + + +# Candidate distance-axis steps (metres): fine indoor splits up to ultra range. +_DISTANCE_STEPS = [50, 100, 200, 250, 500, 1000, 2000, 2500, 5000, 10000, 20000, 50000, 100000] + + +def _distance_step(d_max: float, target_ticks: int = 5) -> int: + for step in _DISTANCE_STEPS: + if d_max / step <= target_ticks: + return step + return _DISTANCE_STEPS[-1] + + +def _fmt_distance(meters: float, in_km: bool) -> str: + """Format a distance for the chart's x-axis label. + + Args: + meters: A distance in metres. + in_km: Format in kilometres. Chosen once from the axis maximum so the + whole axis stays in one unit (never a mix of ``m`` and ``km``). + + Returns: + A compact, self-labelling value such as ``"750 m"`` or ``"1.5 km"``. + """ + if in_km: + text = f"{meters / 1000.0:.2f}".rstrip("0").rstrip(".") + return f"{text} km" + return f"{meters:,.0f} m" + + +class _ChartSeries: + """One selectable line on the combined chart.""" + + def __init__( + self, + key: str, + label: str, + unit: str, + ys: list[float | None], + color: tuple[str, str], + fmt: Callable[[float], str], + default_on: bool = True, + floor: float | None = None, + ceiling: float | None = None, + stats: tuple[float | None, float | None] | None = None, + ) -> None: + self.key = key + self.label = label + self.unit = unit + self.ys = ys + self.color = color + self.fmt = fmt + self.default_on = default_on + # Plausible value range: the axis is clamped to these so a garbage + # reading (e.g. HR > 250 bpm, speed > 25 km/h) can't distort the scale. + self.floor = floor + self.ceiling = ceiling + self.has_data = any(v is not None for v in ys) + self.stats = stats + + def legend_text(self) -> str: + if not self.has_data: + return f"{self.label} {self.unit}" + if self.stats is None: + return f"{self.label} {self.unit}" + avg, maximum = self.stats + if avg is None or maximum is None: + return f"{self.label} {self.unit}" + return ( + f"{self.label} {self.unit} " + f"avg {self.fmt(avg)} · max {self.fmt(maximum)}" + ) + + def hover_text(self, value: float) -> str: + return f"{self.label} {self.fmt(value)} {self.unit}" + + +class _MultiChart(ctk.CTkFrame): # type: ignore[misc] # customtkinter ships no stubs; base is Any + """Stacked per-metric charts sharing one x axis (time or distance). + + The series have incompatible units (km/h, W, bpm, spm, N), so each + selected series gets its own vertically isolated panel with its own real + value axis, all aligned on the same x axis. Each panel's value axis is + outlier-robust (see :func:`_axis_bounds`): a single garbage sample can't + stretch the scale, and speed/HR are additionally capped to a plausible + ceiling. The legend checkboxes choose which panels are shown and the + canvas grows/shrinks to fit. A segmented toggle switches the shared x axis + between elapsed time (default) and cumulative distance; it is only shown + when the workout actually carries distance data. A hover crosshair spans + every panel: it snaps to the nearest sample and shows the x position plus + each visible series' exact value in its own color. None values break the + lines so pauses show as gaps rather than misleading straight lines — a + missing x coordinate (distance mode) is treated exactly like a missing y + value. + """ + + _PAD_L, _PAD_R, _PAD_T, _PAD_B = 58, 18, 8, 26 + _PANEL_H, _PANEL_GAP = 118, 12 + _PHASE_H, _PHASE_GAP = 24, 6 + + def __init__( + self, + master: Any, + xs_time: list[float], + xs_distance: list[float | None], + series: list[_ChartSeries], + steps: Sequence[tuple[float, float, str, str | None]] | None = None, + breaks: Sequence[bool] | None = None, + time_bounds: tuple[float, float] | None = None, + anchor_t: Sequence[float] | None = None, + anchor_distance: Sequence[float | None] | None = None, + ) -> None: + super().__init__(master, corner_radius=10) + self._xs_time = xs_time + self._xs_distance = xs_distance + # Run-start x for each point, per axis: at a break these begin the line + # at the run boundary (timer START / distance before the first window) + # so the drawn span matches the lap table (D020). + self._anchor_time = list(anchor_t) if anchor_t is not None else [] + self._anchor_distance = ( + list(anchor_distance) if anchor_distance is not None else [] + ) + self._x_mode = "time" # "time" | "distance" + self._dist_km = False # distance axis labelled in km (set per redraw) + # Only offer the distance axis when there is at least one real reading; + # otherwise the chart behaves exactly as the time-only version did. + self._has_distance = any(d is not None for d in xs_distance) + self._series = [s for s in series if s.has_data] + self._steps = list(steps) if steps is not None else [] + self._breaks = list(breaks) if breaks is not None else [] + self._time_bounds = time_bounds + self._visible: dict[str, Any] = {} + self._downsample_cache: dict[tuple[str, str, int], list[int]] = {} + self._resize_job: Any | None = None + + # Legend = the selection UI: one colored checkbox per series. The + # x-axis toggle (when available) sits on the right of the same row. + legend = ctk.CTkFrame(self, fg_color="transparent") + legend.pack(fill="x", padx=10, pady=(8, 0)) + if self._has_distance: + self._x_mode_var = tk.StringVar(value="Time") + ctk.CTkSegmentedButton( + legend, values=["Time", "Distance"], + variable=self._x_mode_var, command=self._set_x_mode, + height=24, font=ctk.CTkFont(size=12), + ).pack(side="right", padx=(6, 2), pady=2) + for s in self._series: + var = tk.BooleanVar(value=s.default_on) + self._visible[s.key] = var + ctk.CTkCheckBox( + legend, + text=s.legend_text(), + variable=var, + command=self._redraw, + text_color=_theme(s.color), + checkbox_width=18, + checkbox_height=18, + border_width=2, + fg_color=_theme(s.color), + hover_color=_theme(s.color), + font=ctk.CTkFont(size=12), + ).pack(side="left", padx=(2, 10), pady=2) + + n_default = sum(1 for s in self._series if s.default_on) + self._canvas = tk.Canvas( + self, height=self._canvas_height(n_default), + highlightthickness=0, bg=_theme(_BG), + ) + self._canvas.pack(fill="x", padx=8, pady=8) + self._canvas.bind("", self._schedule_redraw) + # Hover crosshair: snap to the nearest sample and show exact values. + self._plot: dict[str, Any] | None = None # geometry of last redraw + self._canvas.bind("", self._on_motion) + self._canvas.bind("", lambda _e: self._canvas.delete("hover")) + + # ---- drawing --------------------------------------------------------- + def _visible_series(self) -> list[_ChartSeries]: + return [s for s in self._series if self._visible[s.key].get()] + + def _schedule_redraw(self, _event: Any = None) -> None: + """Debounce resize-driven redraws while the window is being dragged.""" + if self._resize_job is not None: + self.after_cancel(self._resize_job) + self._resize_job = self.after(75, self._redraw) + + def _active_xs(self) -> Sequence[float | None]: + """The x-array for the current mode (may contain None in distance mode). + + Returned as a read-only ``Sequence`` (covariant) so the ``list[float]`` + time axis and the ``list[float | None]`` distance axis share one return + type; callers only iterate/index, never mutate. + """ + return self._xs_distance if self._x_mode == "distance" else self._xs_time + + def _active_anchor_xs(self) -> Sequence[float | None]: + """Run-start x per point for the current mode (empty if not provided).""" + return ( + self._anchor_distance if self._x_mode == "distance" else self._anchor_time + ) + + def _fmt_x(self, value: float) -> str: + """Format an x-axis value for the current mode (time or distance).""" + if self._x_mode == "distance": + return _fmt_distance(value, self._dist_km) + return fmt_time(value) + + def _set_x_mode(self, value: str) -> None: + """Switch the shared x axis between time and distance and redraw. + + Args: + value: The selected segmented-button label ("Time" or "Distance"). + """ + self._x_mode = "distance" if value == "Distance" else "time" + self._canvas.delete("hover") + self._redraw() + + @classmethod + def _phase_space(cls, x_mode: str, has_steps: bool) -> int: + """Vertical space used by the planned-phase strip, when applicable.""" + if x_mode != "time" or not has_steps: + return 0 + return cls._PHASE_H + cls._PHASE_GAP + + def _canvas_height(self, n_panels: int) -> int: + n = max(n_panels, 1) + return ( + self._PAD_T + + self._phase_space(self._x_mode, bool(self._steps)) + + n * self._PANEL_H + + (n - 1) * self._PANEL_GAP + + self._PAD_B + ) + + def _redraw(self) -> None: + self._resize_job = None + c = self._canvas + c.delete("all") + self._plot = None + + shown = self._visible_series() + height = self._canvas_height(len(shown)) + # Resizing to fit the selected panels retriggers once; the + # second pass draws at the final size and stops (same height → no-op). + c.configure(height=height) + w = c.winfo_width() + if w < 120: + return + x0, x1 = self._PAD_L, w - self._PAD_R + grid, text = _theme(_GRID), _theme(_TEXT) + + xs = self._active_xs() + anchor_xs = self._active_anchor_xs() + if not shown or not xs: + c.create_text( + (x0 + x1) / 2, height / 2, + text="no series selected" if self._series else "no data", + fill=text, + ) + return + + # Domain over the valid (non-None) x values only: in distance mode the + # last record can legitimately have a missing reading, so xs[-1] is not + # safe to use as the maximum. + x_max = max((x for x in xs if x is not None), default=1.0) + if self._x_mode == "time" and self._time_bounds is not None: + x_max = max(x_max, self._time_bounds[1]) + x_max = max(x_max, 1.0) + + def px(t: float) -> float: + return x0 + (t / x_max) * (x1 - x0) + + # X ticks are shared by all panels; labels go under the last one. + if self._x_mode == "distance": + x_step: float = float(_distance_step(x_max)) + self._dist_km = x_max >= 1000.0 # keep the whole axis in one unit + else: + x_step = float(_time_step(x_max)) + ticks: list[float] = [] + t = 0.0 + while t <= x_max + 1e-9: + ticks.append(t) + t += x_step + phase_space = self._phase_space(self._x_mode, bool(self._steps)) + panels_top = self._PAD_T + phase_space + bottom = panels_top + len(shown) * self._PANEL_H + ( + len(shown) - 1 + ) * self._PANEL_GAP + + # Planned phases get one compact time-aligned strip instead of a + # repeated background fill in every metric panel. Short steps remain + # visible as color segments without forcing unreadable labels. Their + # boundaries continue through all panels as shared alignment guides. + if phase_space: + phase_y0 = self._PAD_T + phase_y1 = phase_y0 + self._PHASE_H + boundaries: set[float] = set() + for step_start, step_end, _label, kind in self._steps: + left = max(min(step_start, x_max), 0.0) + right = max(min(step_end, x_max), 0.0) + if right <= left: + continue + boundaries.update((left, right)) + fill = _theme(_STEP_FILLS.get(kind or "", _STEP_DEFAULT_FILL)) + c.create_rectangle( + px(left), phase_y0, px(right), phase_y1, + fill=fill, outline="", + ) + if px(right) - px(left) >= 42: + c.create_text( + (px(left) + px(right)) / 2, + (phase_y0 + phase_y1) / 2, + text=(kind or "step").capitalize(), + fill=text, + font=("", 9), + ) + c.create_rectangle(x0, phase_y0, x1, phase_y1, outline=grid) + for boundary in sorted(boundaries): + x = px(boundary) + c.create_line(x, phase_y0, x, phase_y1, fill=grid) + c.create_line(x, panels_top, x, bottom, fill=grid) + + panels: list[tuple[_ChartSeries, float, float, float, float]] = [] + for k, s in enumerate(shown): + py0 = panels_top + k * (self._PANEL_H + self._PANEL_GAP) + py1 = py0 + self._PANEL_H + + authoritative_max = s.stats[1] if s.stats is not None else None + lo, hi = _axis_bounds( + s.ys, + floor=s.floor, + ceiling=s.ceiling, + authoritative_max=authoritative_max, + ) + panels.append((s, lo, hi, py0, py1)) + line = _theme(s.color) + + def py(v: float, lo: float = lo, hi: float = hi, + py0: float = py0, py1: float = py1) -> float: + # Clamp to the panel so a clipped outlier pins to the edge + # instead of drawing outside its frame. + y = py1 - (v - lo) / (hi - lo) * (py1 - py0) + return min(py1, max(py0, y)) + + # Panel frame + axis gridlines clipped to this panel. + c.create_rectangle(x0, py0, x1, py1, outline=grid) + for tick in ticks: + x = px(tick) + c.create_line(x, py0, x, py1, fill=grid) + + # This panel's own value axis (~3 gridlines at nice steps). + step = _nice_step(hi - lo, target_ticks=3) + v = math.ceil(lo / step) * step + while v <= hi + 1e-9: + y = py(v) + c.create_line(x0, y, x1, y, fill=grid) + c.create_text( + x0 - 8, y, text=s.fmt(v), anchor="e", fill=text, + font=("", 10), + ) + v += step + + # Series name, and a dashed average line. + c.create_text( + x0 + 6, py0 + 9, text=f"{s.label} ({s.unit})", + anchor="w", fill=line, font=("", 10, "bold"), + ) + authoritative_avg = s.stats[0] if s.stats is not None else None + if authoritative_avg is not None and lo < authoritative_avg < hi: + y = py(authoritative_avg) + c.create_line(x0, y, x1, y, fill=line, dash=(4, 4)) + c.create_text( + x1 - 4, y - 8, text=f"avg {s.fmt(authoritative_avg)}", + anchor="e", fill=line, font=("", 10), + ) + + # Polyline, broken at None gaps. A missing x coordinate (possible + # in distance mode) breaks the line exactly like a missing value. + cache_key = (self._x_mode, s.key, max(int(x1 - x0), 1)) + indices = self._downsample_cache.get(cache_key) + if indices is None: + indices = _downsample_indices( + xs, s.ys, self._breaks, max(int(x1 - x0), 1) + ) + self._downsample_cache[cache_key] = indices + points: list[float] = [] + for i in indices: + xt = xs[i] + yv = s.ys[i] + is_break = i < len(self._breaks) and self._breaks[i] + if is_break and points: + if len(points) >= 4: + c.create_line(*points, fill=line, width=2) + elif len(points) == 2: + x, y = points + c.create_oval( + x - 2, y - 2, x + 2, y + 2, fill=line, outline=line + ) + points = [] + if xt is None or yv is None: + if len(points) >= 4: + c.create_line(*points, fill=line, width=2) + points = [] + continue + # Begin each run's line at its start boundary (timer START / + # cumulative distance before the first window) so the drawn span + # covers the first sample window and matches the lap table (D020). + if is_break: + anchor_x = anchor_xs[i] if i < len(anchor_xs) else None + if anchor_x is not None and anchor_x < xt: + points.extend((px(anchor_x), py(yv))) + points.extend((px(xt), py(yv))) + if len(points) >= 4: + c.create_line(*points, fill=line, width=2) + elif len(points) == 2: # single isolated point + x, y = points + c.create_oval(x - 2, y - 2, x + 2, y + 2, fill=line, outline=line) + + # X labels once, under the bottom panel. Edge labels are anchored + # inward so they are never clipped by the canvas border. + for tick in ticks: + x = px(tick) + anchor: Literal["n", "nw", "ne"] = "n" + if x < x0 + 24: + anchor = "nw" + elif x > x1 - 24: + anchor = "ne" + c.create_text( + x, bottom + 6, text=self._fmt_x(tick), anchor=anchor, + fill=text, font=("", 10), + ) + + # Non-None (index, x) pairs, in x order, for the hover crosshair to + # bisect over — distance mode can contain None, which bisect can't + # compare. In time mode this is every index with xs unchanged, so the + # snapping is identical to the pre-toggle behavior. + valid_pairs = [(i, x) for i, x in enumerate(xs) if x is not None] + + # Geometry for the hover crosshair. + self._plot = { + "x0": x0, "x1": x1, "top": panels_top, "bottom": bottom, + "x_max": x_max, "panels": panels, + "valid_pairs": valid_pairs, + "valid_xs": [x for _, x in valid_pairs], + } + + # ---- hover crosshair --------------------------------------------------- + def _on_motion(self, event: Any) -> None: + c = self._canvas + c.delete("hover") + plot = self._plot + valid_xs: list[float] = plot["valid_xs"] if plot else [] + if plot is None or not valid_xs: + return + x0, x1 = plot["x0"], plot["x1"] + top, bottom = plot["top"], plot["bottom"] + if not (x0 - 4 <= event.x <= x1 + 4): + return + + # Snap to the sample nearest to the cursor. Bisect only over the valid + # (non-None) x values, then map back to the real record index; in time + # mode valid_xs == xs so this is identical to the old direct bisect. + t = (min(max(event.x, x0), x1) - x0) / (x1 - x0) * plot["x_max"] + j = bisect.bisect_left(valid_xs, t) + if j > 0 and ( + j >= len(valid_xs) or abs(valid_xs[j - 1] - t) <= abs(valid_xs[j] - t) + ): + j -= 1 + i, xi = plot["valid_pairs"][j] + tx = x0 + (xi / plot["x_max"]) * (x1 - x0) + + grid, text = _theme(_GRID), _theme(_TEXT) + # One crosshair spanning every panel, plus a dot per panel. + c.create_line(tx, top, tx, bottom, fill=text, dash=(2, 3), tags="hover") + + rows: list[tuple[str, str]] = [(self._fmt_x(xi), text)] + for s, lo, hi, py0, py1 in plot["panels"]: + v = s.ys[i] + if v is None: + continue + y = py1 - (v - lo) / (hi - lo) * (py1 - py0) + y = min(py1, max(py0, y)) # match the clamped polyline + color = _theme(s.color) + c.create_oval( + tx - 3, y - 3, tx + 3, y + 3, + fill=color, outline=color, tags="hover", + ) + rows.append((s.hover_text(v), color)) + + # Tooltip box, flipped to the left near the right edge. + line_h, pad = 16, 8 + box_w = 10 + max(7 * len(r[0]) for r in rows) + pad + box_h = pad * 2 + line_h * len(rows) + bx = tx + 12 if tx + 12 + box_w <= x1 + self._PAD_R else tx - 12 - box_w + by = min(max(event.y - box_h / 2, top), max(bottom - box_h, top)) + c.create_rectangle( + bx, by, bx + box_w, by + box_h, + fill=_theme(_BG), outline=grid, tags="hover", + ) + for n, (label, color) in enumerate(rows): + c.create_text( + bx + pad, by + pad + line_h * n + line_h / 2, + text=label, anchor="w", fill=color, + font=("", 10, "bold" if n == 0 else ""), tags="hover", + ) + + +def _fmt_opt(value: float | None, digits: int = 0) -> str: + if value is None: + return "—" + return f"{value:,.{digits}f}" + + +def _fmt_kmh(speed_mps: float | None) -> str: + if speed_mps is None: + return "—" + return f"{speed_mps * MPS_TO_KMH:,.1f}" + + +def _bind_tooltip(widget: Any, text: str) -> None: + """Show a compact explanation while the pointer rests on ``widget``.""" + state: dict[str, tk.Toplevel | None] = {"window": None} + + def hide(_event: Any = None) -> None: + window = state["window"] + if window is not None: + state["window"] = None + try: + if window.winfo_exists(): + window.destroy() + except tk.TclError: + pass + + def show(_event: Any = None) -> None: + if state["window"] is not None or not widget.winfo_exists(): + return + window = tk.Toplevel(widget) + window.wm_overrideredirect(True) + bg = "#2b2b2b" if ctk.get_appearance_mode() == "Dark" else "#fff8dc" + fg = "#f2f2f2" if ctk.get_appearance_mode() == "Dark" else "#1a1a1a" + tk.Label( + window, + text=text, + justify="left", + wraplength=320, + background=bg, + foreground=fg, + relief="solid", + borderwidth=1, + padx=7, + pady=5, + ).pack() + window.update_idletasks() + x = min( + widget.winfo_rootx(), + max(widget.winfo_screenwidth() - window.winfo_reqwidth() - 8, 0), + ) + y = min( + widget.winfo_rooty() + widget.winfo_height() + 4, + max(widget.winfo_screenheight() - window.winfo_reqheight() - 8, 0), + ) + window.wm_geometry(f"+{x}+{y}") + state["window"] = window + + widget.bind("", show, add="+") + widget.bind("", hide, add="+") + widget.bind("", hide, add="+") + + +def _stat_block( + master: Any, + label: str, + value: str, + sub: str = "", + tooltip: str | None = None, +) -> None: + box = ctk.CTkFrame(master, corner_radius=10) + box.pack(side="left", expand=True, fill="both", padx=4) + ctk.CTkLabel( + box, text=value, font=ctk.CTkFont(size=20, weight="bold") + ).pack(anchor="w", padx=12, pady=(10, 0)) + visible_label = f"{label} ⓘ" if tooltip else label + subtext = f"{visible_label} · {sub}" if sub else visible_label + subtitle = ctk.CTkLabel( + box, text=subtext, text_color=_TEXT, font=ctk.CTkFont(size=12) + ) + subtitle.pack(anchor="w", padx=12, pady=(0, 10)) + if tooltip: + subtitle.configure(cursor="question_arrow") + _bind_tooltip(subtitle, tooltip) + + +def _hr_zone_parts( + summary: WorkoutSummary, +) -> tuple[list[tuple[str, float, tuple[str, str]]], float]: + """Return zone/no-signal seconds normalized against total moving time.""" + measured_hr_s = sum(summary.zone_seconds) + total = max(float(summary.active_time_s), measured_hr_s) + no_hr_s = max(total - measured_hr_s, 0.0) + parts = [ + (name, secs, color) + for name, secs, color in zip( + _ZONE_NAMES, summary.zone_seconds, _ZONE_COLORS, strict=True + ) + ] + if no_hr_s > 0: + parts.append(("No HR signal", no_hr_s, _NO_HR_COLOR)) + return parts, total + + +def _build_zone_bar(master: Any, summary: WorkoutSummary) -> None: + """Stacked horizontal time-in-zone bar with a legend underneath.""" + parts, total = _hr_zone_parts(summary) + if total <= 0: + return + frame = ctk.CTkFrame(master, corner_radius=10) + frame.pack(fill="x", padx=10, pady=(0, 8)) + ctk.CTkLabel( + frame, text="Time in heart-rate zones", + font=ctk.CTkFont(size=12, weight="bold"), text_color=_TEXT, + ).pack(anchor="w", padx=14, pady=(10, 4)) + + canvas = tk.Canvas(frame, height=26, highlightthickness=0, bg=_theme(_BG)) + canvas.pack(fill="x", padx=14, pady=(0, 6)) + + def redraw(_e: Any = None) -> None: + canvas.delete("all") + w = canvas.winfo_width() + if w < 50: + return + x = 0.0 + for _name, secs, color in parts: + if secs <= 0: + continue + width = w * secs / total + canvas.create_rectangle( + x, 2, x + width, 24, fill=_theme(color), width=0 + ) + if width > 48: + canvas.create_text( + x + width / 2, 13, text=fmt_time(secs), + fill=_theme(_ZONE_TEXT), font=("", 10), + ) + x += width + + canvas.bind("", redraw) + + legend = ctk.CTkFrame(frame, fg_color="transparent") + legend.pack(anchor="w", padx=12, pady=(0, 10)) + for name, secs, color in parts: + pct = 100.0 * secs / total + ctk.CTkLabel( + legend, + text=f"{name} {pct:,.0f}%", + fg_color=color, + text_color=_ZONE_TEXT, + corner_radius=6, padx=8, pady=2, + font=ctk.CTkFont(size=11), + ).pack(side="left", padx=2) + + +def _build_splits_table(master: Any, summary: WorkoutSummary) -> None: + """Build a splits table, appending rows in short UI-friendly batches.""" + frame = ctk.CTkFrame(master, corner_radius=10) + frame.pack(fill="x", padx=10, pady=(0, 12)) + ctk.CTkLabel( + frame, text="Splits", font=ctk.CTkFont(size=12, weight="bold"), + text_color=_TEXT, + ).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(10, 4)) + + # Program-driven workouts carry the step behind each lap; show it so a + # rest lap the athlete paddled through is not an anonymous number. + show_step = any(lap.label or lap.kind for lap in summary.laps) + show_gap = any(lap.pause_after_s > 0 for lap in summary.laps) + headers = ["Lap"] + if show_step: + headers.append("Step") + headers.extend(["Start", "Elapsed", "Active"]) + if show_gap: + headers.append("Gap after") + headers.extend( + [ + "Distance", + "Avg sensor\nspeed (km/h)", + "Avg HR", + "Avg est.\nW", + "Avg SPM", + ] + ) + left_cols = 2 if show_step else 1 # left-aligned leading columns + header_font = ctk.CTkFont(size=12, weight="bold") + cell_font = ctk.CTkFont(size=12) + for col, text in enumerate(headers): + ctk.CTkLabel( + frame, text=text, anchor="w" if col < left_cols else "e", + font=header_font, text_color=_TEXT, + ).grid( + row=1, column=col, sticky="ew", + padx=(14 if col == 0 else 6, 14 if col == len(headers) - 1 else 6), + pady=2, + ) + frame.grid_columnconfigure(col, weight=1) + + def cells_for(lap: Any) -> list[str]: + cells = [str(lap.index)] + if show_step: + step_text = lap.label or (lap.kind or "").capitalize() or "—" + cells.append(step_text) + cells.extend( + [ + fmt_time_precise(lap.start_s), + fmt_time_precise(lap.elapsed_time_s), + fmt_time_precise(lap.active_time_s), + ] + ) + if show_gap: + cells.append(fmt_time_precise(lap.pause_after_s)) + cells.extend( + [ + fmt_metres(lap.distance_m), + _fmt_kmh(lap.avg_speed_mps), + _fmt_opt(lap.avg_hr), + _fmt_opt(lap.avg_power), + _fmt_opt(lap.avg_spm), + ] + ) + return cells + + next_row = 0 + progress = ctk.CTkLabel( + frame, text="", text_color=_TEXT, font=ctk.CTkFont(size=11) + ) + + def render_rows(batch_size: int) -> None: + """Append at most one small batch, yielding to Tk between batches.""" + nonlocal next_row + if not frame.winfo_exists(): + return + progress.grid_forget() + end = min(next_row + batch_size, len(summary.laps)) + for index in range(next_row, end): + lap = summary.laps[index] + cells = cells_for(lap) + row = index + 2 + is_last = index == len(summary.laps) - 1 + for col, text in enumerate(cells): + ctk.CTkLabel( + frame, text=text, anchor="w" if col < left_cols else "e", + font=cell_font, + ).grid( + row=row, column=col, sticky="ew", + padx=(14 if col == 0 else 6, 14 if col == len(cells) - 1 else 6), + pady=(1, 10 if is_last else 1), + ) + next_row = end + if next_row < len(summary.laps): + progress.configure(text=f"Loading splits… {next_row}/{len(summary.laps)}") + progress.grid( + row=next_row + 2, column=0, columnspan=len(headers), pady=(4, 8) + ) + frame.after( + _SPLIT_RENDER_DELAY_MS, + lambda: render_rows(_SPLIT_RENDER_BATCH_ROWS), + ) + + render_rows(_INITIAL_SPLIT_ROWS) + + +def _load_summary(csv_path: str) -> WorkoutSummary: + """Load and compute a summary without touching Tk.""" + metadata = load_workout_metadata(csv_path) + config = metadata["processing_config"] + assert isinstance(config, dict) + table = CsvReader(csv_path).read_all() + zones = None + if str(config.get("hr_zone_mode", "auto")).lower() == "manual": + zones = list(config.get("hr_zones") or []) or None + return compute_summary( + table, + max_hr=config.get("max_hr", 185), + zones=zones, + program_steps=( + load_program_steps(csv_path) + if metadata["workout_mode"] == "planned" + else None + ), + pull_length_m=config["pull_length_m"], + ) + + +def _render_summary_window(root: Any, csv_path: str, summary: WorkoutSummary) -> None: + """Create Tk widgets for an already-computed summary.""" + win = ctk.CTkToplevel(master=root) + when = ( + summary.start_time.strftime("%A %d %B %Y · %H:%M") + if summary.start_time + else Path(csv_path).name + ) + win.title(f"Workout summary — {when}") + center_over_parent(win, root, 900, 780) + win.minsize(680, 480) + win.transient(root) + win.after(100, win.lift) + win.after(120, win.focus_force) + + body = ctk.CTkScrollableFrame(win, fg_color="transparent") + body.pack(fill="both", expand=True, padx=6, pady=6) + + ctk.CTkLabel( + body, text=when, font=ctk.CTkFont(size=16, weight="bold") + ).pack(anchor="w", padx=12, pady=(12, 8)) + + stats = ctk.CTkFrame(body, fg_color="transparent") + stats.pack(fill="x", padx=8, pady=(0, 8)) + _stat_block(stats, "Active distance", fmt_metres(summary.distance_m)) + _stat_block( + stats, + "Active time", + fmt_time_precise(summary.active_time_s), + f"elapsed {fmt_time_precise(summary.elapsed_time_s)}" + f" · pause {fmt_time_precise(summary.pause_time_s)}", + ) + _stat_block( + stats, "Avg sensor speed", _fmt_kmh(summary.avg_speed_mps), + f"km/h · max {_fmt_kmh(summary.max_speed_mps)}", + ) + _stat_block( + stats, "Avg HR", _fmt_opt(summary.avg_hr), + f"max {_fmt_opt(float(summary.max_hr) if summary.max_hr else None)}", + ) + _stat_block( + stats, "Avg est. power", _fmt_opt(summary.avg_power), + f"max sample {_fmt_opt(float(summary.max_power) if summary.max_power else None)}", + tooltip=_POWER_TOOLTIP, + ) + _stat_block( + stats, + "Avg stroke rate", + _fmt_opt(summary.avg_spm), + f"spm · max {_fmt_opt(float(summary.max_spm) if summary.max_spm else None)}", + ) + + details = ctk.CTkFrame(body, fg_color="transparent") + details.pack(fill="x") + preparing = ctk.CTkLabel( + details, + text="Preparing charts and splits…", + text_color=_TEXT, + font=ctk.CTkFont(size=12), + ) + preparing.pack(anchor="w", padx=14, pady=(4, 12)) + + def render_details() -> None: + """Build expensive widgets after the headline window has painted.""" + if not win.winfo_exists() or not details.winfo_exists(): + return + preparing.destroy() + fmt1 = lambda v: f"{v:,.1f}" + fmt0 = lambda v: f"{v:,.0f}" + _MultiChart( + details, + summary.series_t, + summary.series_distance, + [ + _ChartSeries( + "speed", "Sensor speed", "km/h", + _kmh(summary.series_speed), + _SPEED, fmt1, floor=0.0, ceiling=25.0, + stats=( + summary.avg_speed_mps * MPS_TO_KMH + if summary.avg_speed_mps is not None else None, + summary.max_speed_mps * MPS_TO_KMH + if summary.max_speed_mps is not None else None, + ), + ), + _ChartSeries( + "power", "Est. power", "W", + summary.series_power, + _POWER, fmt0, floor=0.0, + stats=( + summary.avg_power, + float(summary.max_power) if summary.max_power else None, + ), + ), + _ChartSeries( + "hr", "HR", "bpm", + summary.series_hr, _HR, fmt0, + floor=0.0, ceiling=250.0, + stats=( + summary.avg_hr, + float(summary.max_hr) if summary.max_hr else None, + ), + ), + _ChartSeries( + "spm", "Cadence", "spm", + summary.series_spm, _CADENCE, + fmt0, default_on=False, floor=0.0, + stats=( + summary.avg_spm, + float(summary.max_spm) if summary.max_spm else None, + ), + ), + _ChartSeries( + "pull", "Pull", "N", + summary.series_pull, _PULL, + fmt0, default_on=False, floor=0.0, + ), + ], + steps=summary.series_steps, + breaks=summary.series_breaks, + time_bounds=summary.series_time_bounds, + anchor_t=summary.series_anchor_t, + anchor_distance=summary.series_anchor_distance, + ).pack(fill="x", padx=10, pady=(0, 8)) + + if any(v is not None for v in summary.series_hr): + _build_zone_bar(details, summary) + if summary.laps: + _build_splits_table(details, summary) + + win.after(_DETAIL_RENDER_DELAY_MS, render_details) + + +def open_summary_window( + root: Any, csv_path: str | None, config: dict[str, Any] +) -> None: + """Load a workout summary in the background, then open its window. + + Args: + root: Parent window. + csv_path: Path to the workout CSV file. ``None``/empty is a no-op (the + summary button can be wired before a workout has been recorded). + config: Recorded configuration used for HR zones and power calibration. + """ + if not csv_path: + return + key = str(Path(csv_path).resolve()) + existing = _SUMMARY_LOADS.get(key) + if existing is not None and existing.winfo_exists(): + existing.lift() + return + loading = ctk.CTkToplevel(master=root) + _SUMMARY_LOADS[key] = loading + loading.title("Workout summary") + loading.transient(root) + center_over_parent(loading, root, 360, 120) + ctk.CTkLabel(loading, text="Loading workout summary…").pack( + expand=True, padx=24, pady=(20, 8) + ) + progress = ctk.CTkProgressBar(loading, mode="indeterminate") + progress.pack(fill="x", padx=24, pady=(0, 20)) + progress.start() + + def run() -> None: + try: + summary = _load_summary(csv_path) + error: Exception | None = None + except Exception as exc: + summary = None + error = exc + + def finish() -> None: + _SUMMARY_LOADS.pop(key, None) + if loading.winfo_exists(): + progress.stop() + loading.destroy() + if error is not None: + messagebox.showerror( + "Workout summary", f"Could not read workout:\n{error}", parent=root + ) + elif summary is not None: + _render_summary_window(root, csv_path, summary) + + root.after(0, finish) + + threading.Thread(target=run, daemon=True).start() diff --git a/gui/window_utils.py b/gui/window_utils.py new file mode 100644 index 0000000..08b5a16 --- /dev/null +++ b/gui/window_utils.py @@ -0,0 +1,116 @@ +"""Small window-placement helpers shared by all KayakFit windows. + +Tk opens new windows wherever the window manager likes (often the top-left +corner, or stacked exactly over the previous window). For a polished feel — +and clean screenshots — the main window opens centered on the screen and +every dialog opens centered over its parent, clamped so it never runs off +the visible screen. + +Known limitation: Tk reports only the primary display (Windows) or the +combined virtual desktop (X11), so clamping is skipped when the parent lies +outside those bounds (e.g. on a second monitor) rather than dragging the +dialog onto the wrong screen. +""" + +import contextlib +import os +import subprocess +import sys +import tkinter as tk +from pathlib import Path + +# Window types accepted by the placement helpers (covers CTk/CTkToplevel, +# which subclass tk.Tk/tk.Toplevel). +_Window = tk.Tk | tk.Toplevel + +# Margins kept free at the screen edges (dock / taskbar at the bottom). +_SCREEN_MARGIN_BOTTOM = 60 +_SCREEN_MARGIN_TOP = 30 +_SCREEN_MARGIN_SIDE = 30 + +# Tk's point->pixel scaling is DPI/72. The norm is ~1.0 on macOS (Retina +# backing is handled by the OS, not by Tk) but 96/72 ~= 1.333 on Windows/X11 +# at 100% OS scaling — and legitimately higher on HiDPI displays (~1.667 at +# 125%, ~2.667 at 200%), so those values must NOT be clamped. Some +# virtualized or headless displays report a bogus DPI, so Tk picks an absurd +# factor and every point-sized font/widget renders oversized — windows then +# overflow the screen. We only override clearly out-of-range values so a +# correctly detected host display is never touched. +_SCALING_RANGE_MAC = (0.7, 1.4) +_SCALING_RANGE_OTHER = (0.9, 4.0) + + +def open_in_file_manager(path: str | Path) -> None: + """Open a path in Finder, Explorer, or the freedesktop file manager.""" + target = str(path) + if sys.platform == "darwin": + subprocess.Popen(["open", target]) + elif os.name == "nt": + os.startfile(target) # type: ignore[attr-defined] + else: + subprocess.Popen(["xdg-open", target]) + + +def normalize_tk_scaling(root: tk.Misc) -> None: + """Reset Tk scaling when the detected value is implausible for the platform. + + Fixes oversized UI on VMs/remote displays that mis-report screen DPI while + leaving correctly configured displays (including Windows/Linux HiDPI) + unchanged. Best-effort: any failure leaves the default scaling in place. + """ + if sys.platform == "darwin": + baseline = 1.0 + lo, hi = _SCALING_RANGE_MAC + else: + baseline = 96 / 72 # Tk's Windows/X11 norm at 100% OS scaling + lo, hi = _SCALING_RANGE_OTHER + try: + current = float(root.tk.call("tk", "scaling")) + except Exception: + return + if current < lo or current > hi: + with contextlib.suppress(Exception): + root.tk.call("tk", "scaling", baseline) + + +def _clamp_to_screen(win: _Window, width: int, height: int) -> tuple[int, int, int, int]: + """Shrink a requested size so it fits within the usable screen area.""" + sw, sh = win.winfo_screenwidth(), win.winfo_screenheight() + width = min(width, max(sw - 2 * _SCREEN_MARGIN_SIDE, 320)) + height = min(height, max(sh - _SCREEN_MARGIN_TOP - _SCREEN_MARGIN_BOTTOM, 320)) + return width, height, sw, sh + + +def center_on_screen(win: _Window, width: int, height: int) -> None: + """Size ``win`` and center it on the screen (slightly above the middle).""" + win.update_idletasks() + width, height, sw, sh = _clamp_to_screen(win, width, height) + x = max((sw - width) // 2, 0) + y = max((sh - height) // 3, _SCREEN_MARGIN_TOP) # optical center + win.geometry(f"{width}x{height}+{x}+{y}") + + +def center_over_parent(win: _Window, parent: tk.Misc, width: int, height: int) -> None: + """Size ``win`` and center it over ``parent``, kept fully on screen.""" + win.update_idletasks() + width, height, sw, sh = _clamp_to_screen(win, width, height) + try: + px, py = parent.winfo_rootx(), parent.winfo_rooty() + pw, ph = parent.winfo_width(), parent.winfo_height() + except Exception: # parent destroyed or in teardown + pw = ph = 0 + px = py = 0 + if pw <= 1 or ph <= 1: + # An unmapped window reports 1x1 (Tk never raises for this) — centering + # over it would pin the dialog to the parent's top-left corner. + center_on_screen(win, width, height) + return + x = px + (pw - width) // 2 + y = py + (ph - height) // 2 + if 0 <= px < sw and 0 <= py < sh: + # Parent is on the screen Tk knows about — keep the dialog fully + # visible. When the parent sits beyond the reported bounds (second + # monitor), trust the parent-centered position instead. + x = min(max(x, 0), max(sw - width, 0)) + y = min(max(y, _SCREEN_MARGIN_TOP), max(sh - height - _SCREEN_MARGIN_BOTTOM, 0)) + win.geometry(f"{width}x{height}+{x}+{y}") diff --git a/gui/worker_manager.py b/gui/worker_manager.py new file mode 100644 index 0000000..b33e5c0 --- /dev/null +++ b/gui/worker_manager.py @@ -0,0 +1,385 @@ +"""Background worker-thread management for the GUI. + +Runs the workout and export workers on daemon threads so BLE capture, CSV +writing, FIT export and Strava upload never block the interface, and marshals +every UI touch (log lines, structured events, token-refresh notifications, +blocking prompts) back onto the GUI thread via ``widget.after`` — tolerating +app shutdown mid-flight. Stop and step-advance requests are delivered to the +workers through shared ``threading.Event`` flags; ``cleanup()`` waits briefly +for a running thread on shutdown. +""" + +import asyncio +import contextlib +import queue +import threading +import traceback +from collections.abc import Callable +from tkinter import messagebox +from typing import Any, cast + +from app.events import UiEvent +from app.worker_result import WorkerResult + + +class WorkerManager: + """Manages worker thread execution and output capture.""" + + def __init__(self, log_callback: Callable[[str], None], widget: Any) -> None: + """Initialize worker manager. + + Args: + log_callback: Function to call for logging messages (handles memory limits). + widget: Tkinter widget for scheduling GUI updates (via .after()). + """ + self.log = log_callback + self.widget = widget + self.worker_thread: threading.Thread | None = None + self.stop_flag = threading.Event() + self.advance_flag = threading.Event() # request to advance a program step + self.workout_worker: Callable[..., Any] | None = None + self.export_worker: Callable[..., Any] | None = None + self.on_tokens_updated: Callable[[UiEvent], None] | None = None + self.on_event: Callable[[UiEvent], None] | None = None + self._event_lock = threading.Lock() + self._replaceable_events: dict[str, UiEvent] = {} + self._event_drain_scheduled = False + + def set_token_update_callback( + self, callback: Callable[[UiEvent], None] + ) -> None: + """Register a callback invoked (on the GUI thread) when Strava tokens are refreshed. + + Args: + callback: Function receiving the worker result dict with new tokens. + """ + self.on_tokens_updated = callback + + def set_event_callback(self, callback: Callable[[UiEvent], None]) -> None: + """Register a callback for structured UI events (metrics, status, upload). + + The callback is always invoked on the GUI thread. + + Args: + callback: Function receiving an event dict with a ``type`` key. + """ + self.on_event = callback + + def _ui(self, fn: Callable[[], None]) -> None: + """Schedule a callable on the GUI thread, tolerating app shutdown. + + ``widget.after`` raises (Runtime/TclError) once the Tk interpreter is + being destroyed; a worker thread finishing during shutdown must not + crash on that. + """ + with contextlib.suppress(Exception): + self.widget.after(0, fn) + + def _threadsafe_log(self, message: str) -> None: + """Log callback safe to invoke from any thread. + + Worker code (and its logging handlers) runs on background threads, but + the log sink is a Tk text widget. Marshal every line onto the GUI + thread instead of touching the widget from the worker thread. + """ + # `message` is a plain parameter (not a loop variable), so capturing it + # directly in a zero-arg lambda is safe and lets mypy infer the lambda's + # type against _ui's Callable[[], None] (a defaulted `m=message` param + # can't be matched to the zero-arg expected type). + self._ui(lambda: self.log(message)) + + def _emit(self, event: UiEvent) -> None: + """Marshal a UI event onto the GUI thread.""" + replaceable_key: str | None = None + if event.get("type") == "metrics": + replaceable_key = "metrics" + elif ( + event.get("type") == "status" + and event.get("event") == "program" + and event.get("transition") is None + ): + replaceable_key = "program" + if replaceable_key is None: + if self.on_event: + on_event = self.on_event + self._ui(lambda: on_event(event)) + return + + with self._event_lock: + self._replaceable_events[replaceable_key] = event + if self._event_drain_scheduled: + return + self._event_drain_scheduled = True + self._ui(self._drain_replaceable_events) + + def _drain_replaceable_events(self) -> None: + """Deliver only the newest high-frequency state of each kind.""" + with self._event_lock: + events = list(self._replaceable_events.values()) + self._replaceable_events.clear() + self._event_drain_scheduled = False + if self.on_event: + for event in events: + self.on_event(event) + + def _prompt_user(self, title: str, message: str) -> bool: + """Show a blocking yes/no dialog from a worker thread. + + The dialog is created on the GUI thread (via ``after``); the calling + worker thread blocks on a queue until the user answers. Used by the + workout session to ask whether to continue without a heart-rate monitor. + """ + answer: queue.Queue[bool] = queue.Queue() + + def ask() -> None: + try: + answer.put(bool(messagebox.askyesno(title, message))) + except Exception: # pragma: no cover - dialog failure -> proceed + answer.put(True) + + self._ui(ask) + # Bounded wait so the worker thread can never hang forever if the GUI + # goes away before the dialog is answered (proceed in that case). + try: + return answer.get(timeout=300) + except queue.Empty: + return True + + def set_workers( + self, workout_worker: Callable[..., Any], export_worker: Callable[..., Any] + ) -> None: + """Set worker functions. + + Args: + workout_worker: Function for workout worker + export_worker: Function for export worker + """ + self.workout_worker = workout_worker + self.export_worker = export_worker + + def start_worker( + self, + worker_type: str, + args: dict[str, Any] | None = None, + ) -> bool: + """Start a worker thread. + + Args: + worker_type: Type of worker ('workout' or 'export_upload'). + args: Optional dictionary of arguments to pass to worker. + + Returns: + True if the worker thread was started, False otherwise (so the + caller never flips UI state for a worker that isn't running). + """ + if self.worker_thread and self.worker_thread.is_alive(): + self.log("A task is already running; please wait.\n") + return False + + labels = {"workout": "workout", "export_upload": "export"} + self.log(f"Starting {labels.get(worker_type, worker_type)}...\n") + + # Reset stop / advance flags + self.stop_flag.clear() + self.advance_flag.clear() + + # Start worker thread + try: + if worker_type == "workout": + self.worker_thread = threading.Thread( + target=self._run_workout_worker, args=(args,), daemon=True + ) + elif worker_type == "export_upload": + self.worker_thread = threading.Thread( + target=self._run_export_worker, args=(args,), daemon=True + ) + else: + self.log(f"Error: Unknown worker type: {worker_type}\n") + return False + + self.worker_thread.start() + return True + + except Exception as e: + self.log(f"Error starting worker: {e}\n") + return False + + def _run_workout_worker(self, args: dict[str, Any] | None) -> None: + """Run workout worker in thread. + + Args: + args: Dictionary with boat_weight, person_weight, ergometer_mac + """ + if not self.workout_worker: + self.log("Error: Workout worker not configured\n") + return + + loop = None + try: + # Extract arguments + boat_weight = args.get("boat_weight") if args else None + person_weight = args.get("person_weight") if args else None + ergometer_mac = args.get("ergometer_mac") if args else None + program = args.get("program") if args else None + + # Create new event loop for this thread + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # Run async worker. The log callback must be the thread-safe + # wrapper: it is invoked from this worker thread (via logging + # handlers) but ultimately writes to a Tk widget. + result = loop.run_until_complete( + self.workout_worker( + boat_weight=boat_weight, + person_weight=person_weight, + ergometer_mac=ergometer_mac, + log_callback=self._threadsafe_log, + stop_event=self.stop_flag, + data_callback=lambda d: self._emit(UiEvent(type="metrics", data=d)), + status_callback=lambda s: self._emit( + cast(UiEvent, {"type": "status", **s}) + ), + prompt_callback=self._prompt_user, + program=program, + advance_event=self.advance_flag, + ) + ) + + if not isinstance(result, WorkerResult): + raise TypeError("Workout worker returned no typed terminal result") + self._threadsafe_log( + "\n✓ Finished.\n" if result.success else f"\n✗ {result.message}\n" + ) + except Exception as e: + self._threadsafe_log(f"\n✗ Workout task failed: {e}\n") + result = WorkerResult( + outcome="failed", + stage="worker", + message=str(e), + retryable=True, + ) + finally: + self._emit(result.to_event("workout_result")) + # Clean up event loop aggressively + if loop: + try: + # Cancel all remaining tasks immediately + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + # Run loop briefly to allow cancellations (with timeout) + if pending: + with contextlib.suppress(asyncio.TimeoutError): # Force close anyway + loop.run_until_complete( + asyncio.wait_for( + asyncio.gather(*pending, return_exceptions=True), + timeout=0.5, + ) + ) + loop.close() + except Exception: + pass + + def _run_export_worker(self, args: dict[str, Any] | None) -> None: + """Run export worker in thread. + + Args: + args: Dictionary with file_path, access_token, etc. + """ + if not self.export_worker: + self.log("Error: Export worker not configured\n") + return + + try: + # Extract arguments + file_path = args.get("file_path", "") if args else "" + access_token = args.get("access_token", "") if args else "" + refresh_token = args.get("refresh_token", "") if args else "" + client_id = args.get("client_id", "") if args else "" + client_secret = args.get("client_secret", "") if args else "" + strava_upload = args.get("strava_upload", "yes") if args else "yes" + + # Run worker (thread-safe log wrapper; see _run_workout_worker). + result = self.export_worker( + file_path=file_path, + access_token=access_token, + refresh_token=refresh_token, + client_id=client_id, + client_secret=client_secret, + strava_upload=strava_upload, + log_callback=self._threadsafe_log, + stop_event=self.stop_flag, + ) + + # Check if tokens were updated + if not isinstance(result, WorkerResult): + raise TypeError("Export worker returned no typed terminal result") + if result.tokens_updated: + # Notify main GUI to reload config + self._ui(lambda: self._handle_token_update(result)) + + if result.success: + self._threadsafe_log("\n✓ Finished.\n") + else: + self._threadsafe_log("\n✗ Export failed.\n") + + self._emit(result.to_event("export_result")) + except Exception as e: + self._threadsafe_log(f"\n✗ Export failed: {e}\n{traceback.format_exc()}\n") + self._emit( + WorkerResult( + outcome="failed", + stage="worker", + message=str(e), + retryable=True, + ).to_event("export_result") + ) + + def _handle_token_update(self, result: WorkerResult) -> None: + """Handle token update on the GUI thread by notifying the registered callback.""" + if self.on_tokens_updated: + self.on_tokens_updated(result.to_event("token_update")) + + def advance_program(self) -> None: + """Request the running workout to advance to the next program step.""" + if self.worker_thread and self.worker_thread.is_alive(): + self.advance_flag.set() + + def stop_worker(self) -> None: + """Stop the running worker thread.""" + if not self.worker_thread or not self.worker_thread.is_alive(): + self.log("Nothing is running.\n") + return + + self.log("\nStopping...\n") + + # Threads cannot be force-killed; the workout worker polls this flag + # and shuts the session down cleanly. (Exports run to completion, + # except the Strava processing poll, which honours this flag.) + self.stop_flag.set() + + def is_running(self) -> bool: + """Check if worker is currently running. + + Returns: + bool: True if worker is running. + """ + return self.worker_thread is not None and self.worker_thread.is_alive() + + def cleanup(self) -> None: + """Cleanup resources and ensure threads are stopped.""" + # Only do cleanup if there's actually a thread running + if not self.worker_thread or not self.worker_thread.is_alive(): + self.worker_thread = None + return + + # Set stop flag + self.stop_flag.set() + + # Wait for thread to finish (with short timeout for quick shutdown) + self.worker_thread.join(timeout=2.0) + # Don't wait any longer - let daemon thread cleanup happen in background + + # Clear references + self.worker_thread = None diff --git a/hooks/pyi_rth_mac_ver.py b/hooks/pyi_rth_mac_ver.py new file mode 100644 index 0000000..13621ba --- /dev/null +++ b/hooks/pyi_rth_mac_ver.py @@ -0,0 +1,55 @@ +"""PyInstaller runtime hook: harden ``platform.mac_ver()`` in frozen builds. + +Some macOS environments (notably virtual machines and stripped-down images) +leave the frozen app unable to read +``/System/Library/CoreServices/SystemVersion.plist``, in which case +``platform.mac_ver()`` returns an empty version string. ``darkdetect`` — +imported by customtkinter at startup — then executes +``int(''.split('.')[0])`` at module import time and the app dies with +"invalid literal for int() with base 10: ''" before any window appears. + +Runtime hooks run before application imports, so wrapping ``mac_ver`` here +guarantees every consumer sees a usable version string. Fallback order: +the original implementation, then ``sysctl kern.osproductversion``, then a +modern floor version (so feature checks like dark-mode support enable +rather than crash). + +Sunset condition: re-test a frozen build *without* this hook whenever +customtkinter/darkdetect is bumped — if darkdetect no longer crashes on an +empty ``mac_ver()``, delete this file (and its entry in ``KayakFit.spec``). +""" + +import sys + +if sys.platform == "darwin": + import platform + + _orig_mac_ver = platform.mac_ver + + def _safe_mac_ver( + release: str = "", + versioninfo: tuple[str, str, str] = ("", "", ""), + machine: str = "", + ) -> tuple[str, tuple[str, str, str], str]: + try: + info = _orig_mac_ver(release, versioninfo, machine) + if info and info[0]: + return info + except Exception: + pass + try: + import subprocess + + out = subprocess.run( + ["/usr/sbin/sysctl", "-n", "kern.osproductversion"], + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + if out: + return (out, ("", "", ""), platform.machine()) + except Exception: + pass + return ("12.0", ("", "", ""), platform.machine()) + + platform.mac_ver = _safe_mac_ver diff --git a/kayakfit_gui.py b/kayakfit_gui.py new file mode 100644 index 0000000..a29b3eb --- /dev/null +++ b/kayakfit_gui.py @@ -0,0 +1,113 @@ +"""KayakFit Main Application Entry Point. + +Launches the KayakFit GUI with workout and export workers, and handles signals +for clean shutdown. Initializes logging and crash diagnostics before any GUI +code runs, so failures in a windowed (no-console) build are still recorded. +""" + +import faulthandler +import logging +import signal +import sys +from pathlib import Path +from types import FrameType, TracebackType + +import customtkinter as ctk + +from app.logger import Logger +from export_worker import run_export_worker +from gui.main_gui import KayakFitGUI +from gui.window_utils import normalize_tk_scaling +from workout_worker import run_workout_worker + +log = logging.getLogger(__name__) + +# Interval at which the mainloop wakes so pending Python signal handlers +# (SIGINT/SIGTERM) run promptly. Tk's C event loop otherwise delays them +# until the next GUI event (mouse move, timer), which makes Ctrl+C appear +# dead while the window is idle. +_SIGNAL_HEARTBEAT_MS = 200 + + +def _enable_faulthandler() -> None: + """Write native-crash tracebacks (e.g. from BLE backends) to a log file. + + Best-effort: never blocks startup. The file handle intentionally stays + open for the lifetime of the process (faulthandler requires it). + """ + try: + log_dir = Path.home() / "KayakFit" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + crash_file = open( # noqa: SIM115 - must outlive this function + log_dir / "faulthandler.log", "a", encoding="utf-8" + ) + faulthandler.enable(file=crash_file) + except Exception: + pass + + +def _log_callback_exception( + exc: type[BaseException], + val: BaseException, + tb: TracebackType | None, +) -> None: + """Log unhandled Tk-callback exceptions. + + Tk's default handler prints to stderr, which does not exist in a windowed + PyInstaller build — without this hook such errors vanish silently. + """ + log.error("Unhandled exception in Tk callback", exc_info=(exc, val, tb)) + + +def main() -> None: + # Logging (console + rotating file sink) must be up before any GUI code + # runs so startup problems are captured in ~/KayakFit/logs. + Logger.setup() + _enable_faulthandler() + + ctk.set_appearance_mode("system") + ctk.set_default_color_theme("blue") + + root = ctk.CTk() + root.report_callback_exception = _log_callback_exception + # Correct a mis-detected display DPI (common in VMs/remote sessions) before + # any window is sized, so the UI doesn't render oversized and overflow. + normalize_tk_scaling(root) + app = KayakFitGUI( + root, workout_worker=run_workout_worker, export_worker=run_export_worker + ) + + def signal_handler(sig: int, frame: FrameType | None) -> None: + # Schedule the hard-exit fallback before on_closing: when no worker is + # running on_closing destroys the root immediately, after which + # root.after would raise TclError. + try: + root.after(3000, lambda: sys.exit(0)) + # Defer shutdown to the event loop instead of re-entering Tk from + # whatever point the interpreter happened to be interrupted at. + root.after_idle(app.on_closing) + except Exception: + pass + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + def _heartbeat() -> None: + # No-op wakeup so Python-level signal handlers get a chance to run + # while the Tk mainloop is otherwise idle in C code. + root.after(_SIGNAL_HEARTBEAT_MS, _heartbeat) + + _heartbeat() + + root.mainloop() + sys.exit(0) + + +if __name__ == "__main__": + try: + main() + except Exception: + # Last-resort capture of fatal startup errors (invisible on stderr in + # a windowed build); re-raise so the process still exits non-zero. + logging.getLogger(__name__).critical("Fatal startup error", exc_info=True) + raise diff --git a/presets/01-4x500m-1min-rest.json b/presets/01-4x500m-1min-rest.json new file mode 100644 index 0000000..03890c9 --- /dev/null +++ b/presets/01-4x500m-1min-rest.json @@ -0,0 +1,12 @@ +{ + "name": "4 × 500 m / 1:00 rest", + "steps": [ + {"type": "warmup", "duration": {"kind": "time", "seconds": 300}}, + {"repeat": 4, "steps": [ + {"type": "work", "duration": {"kind": "distance", "meters": 500}, + "target": {"metric": "pace_500", "low": 130, "high": 145}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 60}} + ]}, + {"type": "cooldown", "duration": {"kind": "time", "seconds": 300}} + ] +} diff --git a/presets/02-3x6min-z3.json b/presets/02-3x6min-z3.json new file mode 100644 index 0000000..c093b09 --- /dev/null +++ b/presets/02-3x6min-z3.json @@ -0,0 +1,12 @@ +{ + "name": "3 × 6 min @ Z3 / 2 min easy", + "steps": [ + {"type": "warmup", "duration": {"kind": "time", "seconds": 300}}, + {"repeat": 3, "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 360}, + "target": {"metric": "hr_zone", "low": 3, "high": 3}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 120}} + ]}, + {"type": "cooldown", "duration": {"kind": "time", "seconds": 300}} + ] +} diff --git a/presets/03-pyramid.json b/presets/03-pyramid.json new file mode 100644 index 0000000..03a3c43 --- /dev/null +++ b/presets/03-pyramid.json @@ -0,0 +1,16 @@ +{ + "name": "Pyramid 250-500-750-500-250", + "steps": [ + {"type": "warmup", "duration": {"kind": "time", "seconds": 300}}, + {"type": "work", "duration": {"kind": "distance", "meters": 250}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 90}}, + {"type": "work", "duration": {"kind": "distance", "meters": 500}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 90}}, + {"type": "work", "duration": {"kind": "distance", "meters": 750}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 90}}, + {"type": "work", "duration": {"kind": "distance", "meters": 500}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 90}}, + {"type": "work", "duration": {"kind": "distance", "meters": 250}}, + {"type": "cooldown", "duration": {"kind": "time", "seconds": 300}} + ] +} diff --git a/presets/04-2km-time-trial.json b/presets/04-2km-time-trial.json new file mode 100644 index 0000000..6507a05 --- /dev/null +++ b/presets/04-2km-time-trial.json @@ -0,0 +1,8 @@ +{ + "name": "2 km time trial", + "steps": [ + {"type": "warmup", "duration": {"kind": "time", "seconds": 300}}, + {"type": "work", "label": "2 km TT", "duration": {"kind": "distance", "meters": 2000}}, + {"type": "cooldown", "duration": {"kind": "time", "seconds": 300}} + ] +} diff --git a/pyinstaller_build.bat b/pyinstaller_build.bat new file mode 100644 index 0000000..db95aa8 --- /dev/null +++ b/pyinstaller_build.bat @@ -0,0 +1,18 @@ +@echo off +REM Windows build script for KayakFit (run from the project root). + +REM Uses separate dist-win/build-win folders so a prior macOS build's +REM dist/build (with Unix-symlink-based .app bundle contents) never blocks +REM this cleanup step when the project folder is shared with a Mac. +echo Cleaning previous build... +if exist build-win rmdir /s /q build-win +if exist dist-win rmdir /s /q dist-win + +echo Building app with PyInstaller... +REM uv run --group build installs the PyInstaller build group and runs it inside +REM the project environment (no manual venv activation needed). +uv run --group build pyinstaller KayakFit.spec --distpath dist-win --workpath build-win + +echo. +echo Build complete! +echo App location: dist-win\KayakFit.exe diff --git a/pyinstaller_build.sh b/pyinstaller_build.sh new file mode 100644 index 0000000..b5e00d9 --- /dev/null +++ b/pyinstaller_build.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Uses separate dist-mac/build-mac folders so this never collides with a +# Windows build's dist-win/build-win when the project folder is shared +# between machines (a mac .app bundle contains Unix symlinks that Windows +# can't clean up if it lands in a folder Windows also writes to). +echo "🧹 Cleaning previous build..." +rm -rf build-mac dist-mac + +# Exclude from iCloud sync +mkdir -p build-mac dist-mac +touch build-mac/.nosync dist-mac/.nosync + +echo "🔨 Building app with PyInstaller..." +# `uv run --group build` ensures the PyInstaller build group is installed and +# runs it inside the project environment (no manual venv activation needed). +uv run --group build pyinstaller KayakFit.spec --distpath dist-mac --workpath build-mac + +echo "" +echo "✅ Build complete!" +echo "📦 App location: dist-mac/KayakFit.app" +echo "▶️ Run GUI: open dist-mac/KayakFit.app" +echo "🐛 Debug: ./dist-mac/KayakFit.app/Contents/MacOS/KayakFit" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..11a8e1a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,179 @@ +# Project + tool configuration for KayakFit. +# +# uv is the dependency manager: direct dependencies live in [project] and the +# dev/build tools in [dependency-groups]; `uv lock` pins the full graph into +# uv.lock (the single source of truth for exact versions) and `uv sync` +# installs it. requirements.txt is now a *generated* pip fallback exported from +# the lock (see README.md / CONTRIBUTING.md) — don't hand-edit it. This file +# also configures Ruff (lint + import sort), mypy (type checking) and pytest; +# none of those tools are runtime dependencies of the app. + +[project] +name = "kayakfit" +# Keep in sync with __version__ in app/__init__.py and the release tag — both +# are bumped together (see CONTRIBUTING.md "Versioning"). +version = "0.1.0" +description = "Record a KayakFirst Bull ergometer over BLE, show a live dashboard, and export a Garmin FIT file / upload to Strava." +readme = "README.md" +requires-python = ">=3.14" +# Direct runtime imports only. Platform-specific backends (e.g. the pyobjc +# CoreBluetooth stack bleak needs on macOS) come in transitively with their own +# environment markers, so they are resolved into uv.lock automatically rather +# than being listed here. +dependencies = [ + "bleak==2.0.0", + "customtkinter==5.2.2", + "fit-tool==0.9.15", + "keyring==25.6.0", + "PyYAML==6.0.3", + "requests==2.33.0", +] + +[project.urls] +Homepage = "https://github.com/konverga/KayakFit" +Documentation = "https://github.com/konverga/KayakFit/tree/main/docs" +Issues = "https://github.com/konverga/KayakFit/issues" +Repository = "https://github.com/konverga/KayakFit.git" + +[dependency-groups] +# Lint, type check and test tooling plus the type stubs mypy reads. Installed by +# a default `uv sync`; build/dev-time only — PyInstaller never bundles them. +dev = [ + "ruff==0.15.21", + "mypy==2.2.0", + "pytest>=9.0.3,<10", + "types-PyYAML==6.0.12.20250915", + "types-requests==2.32.4.20250913", + "types-setuptools==80.9.0.20250822", +] +# PyInstaller and its hook helpers — used only to build the desktop bundles, so +# they are kept out of the default dev install (`uv sync --group build`). +build = ["pyinstaller==6.21.0", "pyinstaller-hooks-contrib==2026.6"] + +[tool.uv] +# KayakFit is a desktop app run from source (`uv run python kayakfit_gui.py`) +# and packaged with PyInstaller — not a library to install — so uv manages the +# virtualenv without trying to build/install the project itself. +package = false + +[tool.ruff] +# Match the Python this app targets (see README.md's Requirements section) so +# Ruff's pyupgrade rules can suggest modern 3.14 idioms. +target-version = "py314" +# The codebase was never held to Black's 88-column default; match its actual +# prevailing width instead of forcing a large, low-value reformat. +line-length = 100 +extend-exclude = ["build-mac", "dist-mac", "build-win", "dist-win", ".venv"] + +[tool.ruff.lint] +# A common, practical selection covering the standards in docs/STYLE_GUIDE.md +# (import order, type hints, docstrings, PEP 8) plus a handful of +# widely-used, low-noise correctness/clarity rule sets. +select = [ + "E", # pycodestyle errors (PEP 8) + "W", # pycodestyle warnings (PEP 8) + "F", # pyflakes -- unused imports/variables, undefined names, dead code + "I", # isort -- import grouping/sorting ("Organize imports") + "N", # pep8-naming + "UP", # pyupgrade -- modern idioms for the target Python version + "B", # flake8-bugbear -- common bug patterns + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "A", # flake8-builtins -- don't shadow builtins + "RUF", # Ruff-specific rules + "D", # pydocstyle -- docstring conventions ("Fix docstrings") + "ANN", # flake8-annotations -- signatures carry type hints ("Add type hints") +] +ignore = [ + # Docstring *presence* is a judgment call in this codebase -- public, + # non-trivial code is documented, but small internal helpers, closures, + # and GUI callbacks are deliberately left undocumented (see docs/STYLE_GUIDE.md). + # The rest of the "D" rules still enforce *format* for docstrings that + # do exist (Google convention, below); only presence is exempted here. + "D100", # missing docstring in public module + "D101", # missing docstring in public class + "D102", # missing docstring in public method + "D103", # missing docstring in public function + "D104", # missing docstring in public package + "D105", # missing docstring in magic method + "D106", # missing docstring in nested class + "D107", # missing docstring in __init__ + # A few short formatter lambdas in gui/ (e.g. summary_window.py's + # `fmt1 = lambda v: ...`) are clearer inline than as a one-line def; + # already explicitly suppressed per-line with `# noqa: E731` at each + # existing use, so it's redundant (not wrong) to also flag it globally. + "E731", + # `Any` is used deliberately in this codebase where the value is genuinely + # dynamic (tkinter widget values, BLE `sender` objects, event/target dicts) + # -- docs/STYLE_GUIDE.md says to use `Any` explicitly rather than + # guess a wrong specific type. ANN401 disallows exactly that intentional + # use, so suppress it here (same judgment-call treatment as the D1xx rules + # above) rather than annotating misleadingly-specific types. + "ANN401", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +# stdlib -> third-party -> local, matching docs/STYLE_GUIDE.md +# convention. Explicit rather than relying on auto-detection, since this +# repo isn't a standard src-layout package. +known-first-party = ["app", "gui"] + +[tool.ruff.lint.per-file-ignores] +# Tests use plain asserts and a lightweight annotation convention; keep +# application docstring and annotation-presence rules focused on production +# code. Ruff still checks all other enabled rules in tests. +"tests/*.py" = ["D", "ANN"] + +[tool.mypy] +python_version = "3.14" +# Full strictness (CI runs `uv run mypy . --strict` with this on). `strict = true` turns +# on the whole bundle: no untyped/incomplete defs, no untyped calls, no implicit +# Any generics, warn on returning Any, disallow subclassing Any, no implicit +# re-export, warn on unused ignores, etc. It subsumes the individual +# warn_unused_ignores / warn_redundant_casts / check_untyped_defs flags that +# used to be set here. Deliberate exceptions are pinned with `# type: ignore` +# (e.g. subclassing the unstubbed customtkinter base) or the tests override +# below. ANN401's intentional-`Any` allowance is a Ruff concern, not mypy's. +strict = true +# PyInstaller build output (build-mac/, dist-mac/, build-win/, dist-win/) +# bundles vendored copies of dependencies -- e.g. dist-mac/ ships bleak twice +# (once in KayakFit.app/Contents/Frameworks, once in KayakFit/_internal), +# which mypy flags as "Duplicate module named 'bleak'". These dirs are build +# artifacts, not source (already .gitignored), so exclude them from the type +# check -- mirrors Ruff's extend-exclude above. Separators use [\\/] so the +# regex matches on Windows (dist-win\...) as well as macOS/Linux (dist-mac/...); +# single-quoted TOML literal string avoids double-escaping the backslashes. +exclude = '(^|[\\/])(build-mac|dist-mac|build-win|dist-win|\.venv)[\\/]' +# Third-party GUI/BLE/FIT libraries (customtkinter, bleak, fit_tool, keyring) +# don't ship type stubs; under strict, an unstubbed import would otherwise be a +# hard error -- keep it a non-failure. +ignore_missing_imports = true + +# Tests keep their own lightweight convention: plain-assert `test_*` functions +# that aren't fully annotated (this mirrors the Ruff ANN per-file-ignore for +# tests/ above). Relax ONLY the annotation-presence strictness here -- real type +# errors in tests (missing generic args, attribute typos, non-overlapping +# comparisons) are still reported, so the suite stays type-checked. +# +# Tests are a package so one wildcard covers future modules without a manually +# maintained list. Only annotation-presence checks are relaxed here; real type +# errors remain enabled. +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false +disallow_incomplete_defs = false +disallow_untyped_calls = false + +[tool.pytest.ini_options] +# The test_*.py scripts hold plain `assert`-based test_* functions (see +# docs/STYLE_GUIDE.md). pytest discovers them here. +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +# Repo root on sys.path so `import app` / `import gui` resolve regardless of how +# pytest is invoked. +pythonpath = ["."] +addopts = "-ra" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bb7d382 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,146 @@ +# This file was autogenerated by uv via the following command: +# uv export --format requirements-txt --no-hashes --all-groups -o requirements.txt +altgraph==0.17.5 + # via + # macholib + # pyinstaller +ast-serialize==0.6.0 + # via mypy +bitstruct==8.22.1 + # via fit-tool +bleak==2.0.0 + # via kayakfit +certifi==2026.6.17 + # via requests +cffi==2.1.0 ; platform_python_implementation != 'PyPy' and sys_platform == 'linux' + # via cryptography +charset-normalizer==3.4.9 + # via requests +colorama==0.4.6 ; sys_platform == 'win32' + # via pytest +cryptography==49.0.0 ; sys_platform == 'linux' + # via secretstorage +customtkinter==5.2.2 + # via kayakfit +darkdetect==0.8.0 + # via customtkinter +dbus-fast==5.0.22 ; sys_platform == 'linux' + # via bleak +et-xmlfile==2.0.0 + # via openpyxl +fit-tool==0.9.15 + # via kayakfit +idna==3.18 + # via requests +iniconfig==2.3.0 + # via pytest +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.1.2 + # via keyring +jaraco-functools==4.5.0 + # via keyring +jeepney==0.9.0 ; sys_platform == 'linux' + # via + # keyring + # secretstorage +keyring==25.6.0 + # via kayakfit +librt==0.13.0 ; platform_python_implementation != 'PyPy' + # via mypy +macholib==1.16.4 ; sys_platform == 'darwin' + # via pyinstaller +more-itertools==11.1.0 + # via + # jaraco-classes + # jaraco-functools +mypy==2.2.0 +mypy-extensions==1.1.0 + # via mypy +openpyxl==3.1.5 + # via fit-tool +packaging==26.2 + # via + # customtkinter + # pyinstaller + # pyinstaller-hooks-contrib + # pytest +pathspec==1.1.1 + # via mypy +pefile==2024.8.26 ; sys_platform == 'win32' + # via pyinstaller +pluggy==1.6.0 + # via pytest +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' and sys_platform == 'linux' + # via cffi +pygments==2.20.0 + # via pytest +pyinstaller==6.21.0 +pyinstaller-hooks-contrib==2026.6 + # via pyinstaller +pyobjc-core==12.2.1 ; sys_platform == 'darwin' + # via + # bleak + # pyobjc-framework-cocoa + # pyobjc-framework-corebluetooth + # pyobjc-framework-libdispatch +pyobjc-framework-cocoa==12.2.1 ; sys_platform == 'darwin' + # via + # pyobjc-framework-corebluetooth + # pyobjc-framework-libdispatch +pyobjc-framework-corebluetooth==12.2.1 ; sys_platform == 'darwin' + # via bleak +pyobjc-framework-libdispatch==12.2.1 ; sys_platform == 'darwin' + # via bleak +pytest==9.1.1 +pywin32-ctypes==0.2.3 ; sys_platform == 'win32' + # via + # keyring + # pyinstaller +pyyaml==6.0.3 + # via kayakfit +requests==2.33.0 + # via kayakfit +ruff==0.15.21 +secretstorage==3.5.0 ; sys_platform == 'linux' + # via keyring +setuptools==83.0.0 + # via pyinstaller +types-pyyaml==6.0.12.20250915 +types-requests==2.32.4.20250913 +types-setuptools==80.9.0.20250822 +typing-extensions==4.16.0 + # via + # mypy + # winrt-runtime +urllib3==2.7.0 + # via + # requests + # types-requests +winrt-runtime==3.2.1 ; sys_platform == 'win32' + # via + # bleak + # winrt-windows-devices-bluetooth + # winrt-windows-devices-bluetooth-advertisement + # winrt-windows-devices-bluetooth-genericattributeprofile + # winrt-windows-devices-enumeration + # winrt-windows-devices-radios + # winrt-windows-foundation + # winrt-windows-foundation-collections + # winrt-windows-storage-streams +winrt-windows-devices-bluetooth==3.2.1 ; sys_platform == 'win32' + # via bleak +winrt-windows-devices-bluetooth-advertisement==3.2.1 ; sys_platform == 'win32' + # via bleak +winrt-windows-devices-bluetooth-genericattributeprofile==3.2.1 ; sys_platform == 'win32' + # via bleak +winrt-windows-devices-enumeration==3.2.1 ; sys_platform == 'win32' + # via bleak +winrt-windows-devices-radios==3.2.1 ; sys_platform == 'win32' + # via bleak +winrt-windows-foundation==3.2.1 ; sys_platform == 'win32' + # via bleak +winrt-windows-foundation-collections==3.2.1 ; sys_platform == 'win32' + # via bleak +winrt-windows-storage-streams==3.2.1 ; sys_platform == 'win32' + # via bleak diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..1319f32 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""KayakFit test package.""" diff --git a/tests/test_ble_parsing.py b/tests/test_ble_parsing.py new file mode 100644 index 0000000..3de3a89 --- /dev/null +++ b/tests/test_ble_parsing.py @@ -0,0 +1,104 @@ +""" +Tests for ergometer notification parsing (CRLF line framing). + +Verifies that a single BLE notification carrying multiple complete packets is +fully parsed, that a packet split across two notifications is reassembled, and +that the buffer cannot grow without bound. +""" + +from typing import Any + +from app.kayakfirst_ergometer_bluetooth import KayakFirstErgometer + + +def _make_erg(): + erg = KayakFirstErgometer.__new__(KayakFirstErgometer) + erg.packet_buffer = bytearray() + erg._consecutive_malformed_packets = 0 + erg.status_callback = None + received: list[Any] = [] + erg.callback = received.append + + class _Logger: + def debug(self, msg=""): + pass + + def warning(self, msg=""): + pass + + erg.logger = _Logger() # type: ignore[assignment] + return erg, received + + +def _packet(marker: int) -> bytes: + values = [0] * 23 + values[0] = marker + values[6] = 1 + values[8] = marker * 3 + values[21] = marker + values[22] = 1 + return ("6;" + ";".join(str(value) for value in values)).encode() + + +def test_multiple_packets_in_one_notification() -> None: + erg, received = _make_erg() + erg._on_notify( + None, + bytearray(b"\r\n".join((_packet(1), _packet(2), _packet(3))) + b"\r\n"), + ) + assert len(received) == 3, f"expected 3 packets, got {len(received)}" + assert erg.packet_buffer == bytearray(), "buffer should be empty" + print(" ok: 3 concatenated packets all parsed") + + +def test_packet_split_across_notifications() -> None: + erg, received = _make_erg() + packet = _packet(7) + erg._on_notify(None, bytearray(packet[:20])) + assert received == [], "no complete packet yet" + erg._on_notify(None, bytearray(packet[20:] + b"\r\n")) + assert len(received) == 1, f"expected 1 reassembled packet, got {len(received)}" + print(" ok: packet split across two notifications reassembled") + + +def test_trailing_partial_kept() -> None: + erg, received = _make_erg() + partial = _packet(3)[:12] + erg._on_notify(None, bytearray(_packet(1) + b"\r\n" + partial)) + assert len(received) == 1, "one complete packet" + assert erg.packet_buffer == bytearray(partial), "partial remainder kept" + print(" ok: complete packet parsed, partial remainder buffered") + + +def test_buffer_overflow_guard() -> None: + erg, _received = _make_erg() + erg._on_notify(None, bytearray(b"6" * 5000)) # no terminator + assert erg.packet_buffer == bytearray(), "oversized buffer should be cleared" + print(" ok: buffer overflow without terminator is cleared") + + +def test_incomplete_and_semantically_invalid_packets_are_rejected() -> None: + erg, received = _make_erg() + erg._on_notify(None, bytearray(b"6;1;2\r\n")) + invalid_active = _packet(1).decode().split(";") + invalid_active[7] = "2" + erg._on_notify(None, bytearray((";".join(invalid_active) + "\r\n").encode())) + invalid_window = _packet(2).decode().split(";") + invalid_window[23] = "0" + erg._on_notify(None, bytearray((";".join(invalid_window) + "\r\n").encode())) + decimal_integer = _packet(3).decode().split(";") + decimal_integer[7] = "1.0" + erg._on_notify(None, bytearray((";".join(decimal_integer) + "\r\n").encode())) + assert received == [] + assert erg._consecutive_malformed_packets == 4 + + +def test_five_malformed_packets_emit_device_health_event() -> None: + erg, _received = _make_erg() + events: list[dict[str, Any]] = [] + erg.status_callback = events.append + for _ in range(5): + erg._on_notify(None, bytearray(b"6;1;2\r\n")) + assert events == [ + {"event": "malformed_data", "device": "ergometer", "count": 5} + ] diff --git a/tests/test_controllers.py b/tests/test_controllers.py new file mode 100644 index 0000000..19b3527 --- /dev/null +++ b/tests/test_controllers.py @@ -0,0 +1,43 @@ +"""Typed GUI state-owner tests without constructing Tk widgets.""" + +from pathlib import Path + +from gui.controllers import RecordingController +from gui.history_window import HistoryCatalog + + +def test_recording_controller_resets_workout_state_only() -> None: + state = RecordingController( + active=True, + csv_path="workout.csv", + points=42, + incomplete=True, + persistence_error="disk full", + paused=True, + lap=3, + stop_armed=True, + ) + + state.reset_workout() + + assert state.active is True + assert state.stop_armed is True + assert state.csv_path is None + assert state.points == 0 + assert state.incomplete is False + assert state.persistence_error is None + assert state.paused is False + assert state.lap == 0 + + +def test_history_catalog_owns_deduplication_and_selection() -> None: + catalog = HistoryCatalog() + path = Path("workout.csv") + row = object() + + catalog.register(path, row, "free") + key = catalog.key(path) + + assert catalog.rows_by_key[key] is row + assert catalog.paths_by_key[key] == path + assert catalog.types_by_key[key] == "free" diff --git a/tests/test_device_scanner.py b/tests/test_device_scanner.py new file mode 100644 index 0000000..1c1b1ce --- /dev/null +++ b/tests/test_device_scanner.py @@ -0,0 +1,104 @@ +"""Tests for platform-independent BLE device-name matching.""" + +import asyncio +from types import SimpleNamespace + +from bleak import BleakScanner + +from gui import device_scanner +from gui.setup_wizard import _save_devices + + +def _device(name: str | None, address: str) -> SimpleNamespace: + return SimpleNamespace(name=name, address=address) + + +def _advertisement( + local_name: str | None, service_uuids: list[str] | None = None +) -> SimpleNamespace: + return SimpleNamespace(local_name=local_name, service_uuids=service_uuids or []) + + +def test_ergometer_matches_advertised_name(monkeypatch) -> None: + class PlatformAddress(str): + pass + + async def discover(**_kwargs): + return { + "erg": ( + _device("Generic BLE Device", PlatformAddress("ERG-ADDRESS")), + _advertisement("KF-BOAT-42"), + ) + } + + monkeypatch.setattr(BleakScanner, "discover", discover) + + result = asyncio.run(device_scanner._async_scan("boat-42")) + + assert result["erg_devices"] == [("KF-BOAT-42", "ERG-ADDRESS")] + assert type(result["erg_devices"][0][1]) is str + + +def test_ergometer_matches_platform_device_name(monkeypatch) -> None: + async def discover(**_kwargs): + return { + "erg": ( + _device("Kayak-ABC", "ERG-ADDRESS"), + _advertisement(None), + ) + } + + monkeypatch.setattr(BleakScanner, "discover", discover) + + result = asyncio.run(device_scanner._async_scan("KAYAK-abc")) + + assert result["erg_devices"] == [("Kayak-ABC", "ERG-ADDRESS")] + + +def test_heart_rate_service_remains_authoritative(monkeypatch) -> None: + async def discover(**_kwargs): + return { + "hrm": ( + _device("Kayak-ABC HRM", "HRM-ADDRESS"), + _advertisement(None, [device_scanner.HEART_RATE_SERVICE_UUID.upper()]), + ) + } + + monkeypatch.setattr(BleakScanner, "discover", discover) + + result = asyncio.run(device_scanner._async_scan("kayak-abc")) + + assert result["erg_devices"] == [] + assert result["hrm_devices"] == [("Kayak-ABC HRM", "HRM-ADDRESS")] + + +def test_wizard_saves_selected_device_values() -> None: + cfg: dict[str, object] = {} + erg_map = {"Kayak ABC (ERG-ID)": ("Kayak ABC", "ERG-ID")} + hrm_map = {"Polar H10 (HRM-ID)": ("Polar H10", "HRM-ID")} + + _save_devices( + cfg, + erg_map, + "Kayak ABC (ERG-ID)", + hrm_map, + "Polar H10 (HRM-ID)", + ) + + assert cfg == { + "ergometer_name": "Kayak ABC", + "ergometer_mac": "ERG-ID", + "hrm_name": "Polar H10", + "hrm_mac": "HRM-ID", + } + + +def test_wizard_requires_an_ergometer_selection() -> None: + cfg: dict[str, object] = {} + + try: + _save_devices(cfg, {}, "— scan to find devices —", {}, "None (no heart-rate monitor)") + except ValueError as exc: + assert "Select an ergometer" in str(exc) + else: + raise AssertionError("expected setup to reject a missing ergometer selection") diff --git a/tests/test_fit_events.py b/tests/test_fit_events.py new file mode 100644 index 0000000..4c3ae6e --- /dev/null +++ b/tests/test_fit_events.py @@ -0,0 +1,255 @@ +""" +Verification of the FIT timer-event timeline produced by the exporter. + +The test drives ``FitExporter._add_records`` against a synthetic workout +with two autopauses. It asserts that the emitted message stream: + + * contains balanced START / STOP_ALL timer events (one START per segment, one + STOP_ALL per segment), + * starts with a manual START and ends with a manual STOP_ALL, with automatic + pause/resume events in between, + * adds one metric-free GPS anchor at the start of every active run, + * brackets every moving segment with START ... STOP_ALL, and + * is strictly chronological across both events and records. +""" + +from fit_tool.profile.profile_type import EventType, TimerTrigger + +from app.export_fit import FitExporter +from app.table import Table + + +def _build_workout(): + """Two pauses → three moving segments. Returns a real Table and the base ts.""" + base = 1_700_000_000_000 + ts, dist, speed = [], [], [] + d = 0.0 + t = 0 + + def moving(n): + nonlocal d, t + for _ in range(n): + d += 3.0 + ts.append(base + t) + dist.append(d) + speed.append(3.0) + t += 1000 + + def paused(n): + nonlocal t + for _ in range(n): + ts.append(base + t) + dist.append(d) # distance frozen + speed.append(0.0) + t += 1000 + + moving(8) + paused(6) + moving(8) + paused(6) + moving(8) + + columns = [ + "timestamp", "session_elapsed", "heart_rate", "cadence", "cadence_instant", "distance", + "speed_instant", "pull_force", "pull_force_instant", "active", "sample_duration", + ] + rows = [ + { + "timestamp": base + (i + 2) * 1000, + "session_elapsed": i + 2, + "heart_rate": 120, + "cadence": 80, + "cadence_instant": 80, + "distance": dist[i], + "speed_instant": speed[i], + "pull_force": 200, + "pull_force_instant": 200, + "active": 1 if speed[i] > 0 else 0, + # The first sensor record represents two seconds, matching the + # real regression where Strava previously dropped two seconds. + "sample_duration": 2 if i == 0 else 1, + } + for i in range(len(ts)) + ] + return Table(columns, rows), base + + +class RecordingBuilder: + def __init__(self): + self.messages = [] + + def add(self, message=None): + self.messages.append(message) + + +def _make_exporter(): + exporter = FitExporter.__new__(FitExporter) + exporter.pull_length_m = 0.600 + + class _DummyLogger: + def info(self, msg=""): + pass + + def debug(self, msg=""): + pass + + def warning(self, msg=""): + pass + + def error(self, msg=""): + pass + + exporter.logger = _DummyLogger() # type: ignore[assignment] + return exporter + + +def _emit_stream(): + df, _base = _build_workout() + exporter = _make_exporter() + builder = RecordingBuilder() + segments = exporter._add_records(builder=builder, workout_df=df) + return builder.messages, segments + + +def _classify(msg): + name = type(msg).__name__ + if name == "EventMessage": + return ("event", msg.event_type, msg.timer_trigger, msg.timestamp) + if name == "RecordMessage": + return ("record", None, None, msg.timestamp) + return ("other", None, None, getattr(msg, "timestamp", None)) + + +def test_three_segments_detected() -> None: + _, segments = _emit_stream() + assert len(segments) == 3, f"expected 3 segments, got {len(segments)}" + print(f" ok: two pauses produced {len(segments)} segments") + + +def test_events_balanced_and_typed() -> None: + messages, _ = _emit_stream() + events = [_classify(m) for m in messages if _classify(m)[0] == "event"] + starts = [e for e in events if e[1] == EventType.START.value] + stops = [e for e in events if e[1] == EventType.STOP_ALL.value] + assert len(starts) == 3, f"expected 3 START events, got {len(starts)}" + assert len(stops) == 3, f"expected 3 STOP_ALL events, got {len(stops)}" + + # First event is the manual workout start; last is the manual workout stop. + assert events[0][1] == EventType.START.value + assert events[0][2] == TimerTrigger.MANUAL.value + assert events[-1][1] == EventType.STOP_ALL.value + assert events[-1][2] == TimerTrigger.MANUAL.value + + # Internal pause/resume events are automatic. + for _kind, etype, trigger, _ts in events[1:-1]: + assert trigger == TimerTrigger.AUTO.value, ( + f"internal event not auto: {(etype, trigger)}" + ) + + # Events strictly alternate start, stop, start, stop, ... + types_seq = [e[1] for e in events] + assert types_seq == [ + EventType.START.value, EventType.STOP_ALL.value, EventType.START.value, + EventType.STOP_ALL.value, EventType.START.value, EventType.STOP_ALL.value, + ] + print(f" ok: balanced 3xSTART / 3xSTOP_ALL, correct triggers: {types_seq}") + + +def test_segments_bracketed_and_chronological() -> None: + messages, _ = _emit_stream() + stream = [_classify(m) for m in messages] + + # Open/close bracket counter: +1 on START, -1 on STOP_ALL. A record may only + # appear while a segment is open (counter == 1). + depth = 0 + last_ts = None + for kind, etype, _trigger, ts in stream: + if last_ts is not None and ts is not None: + assert ts >= last_ts, f"non-chronological: {ts} after {last_ts}" + if ts is not None: + last_ts = ts + if kind == "event" and etype == EventType.START.value: + assert depth == 0, "START while a segment was already open" + depth += 1 + elif kind == "event" and etype == EventType.STOP_ALL.value: + assert depth == 1, "STOP_ALL without an open segment" + depth -= 1 + elif kind == "record": + assert depth == 1, "record emitted outside an open segment" + assert depth == 0, "workout ended with an unclosed segment" + print(" ok: every segment bracketed START..STOP_ALL; stream is chronological") + + +def test_records_are_sensor_samples_plus_gps_start_anchors() -> None: + messages, segments = _emit_stream() + records = [m for m in messages if type(m).__name__ == "RecordMessage"] + record_count = len(records) + sensor_count = sum(len(seg.record_indices) for seg in segments) + expected = sensor_count + len(segments) + assert record_count == expected, f"{record_count} records, expected {expected}" + # Three moving blocks of eight plus one compatibility anchor per block. + assert record_count == 27, f"expected 24 sensor + 3 anchor records, got {record_count}" + + anchors = [record for record in records if record.speed is None] + assert len(anchors) == len(segments) == 3 + assert [record.timestamp for record in anchors] == [ + segment.start_time_ms for segment in segments + ] + assert [record.distance for record in anchors] == [0.0, 24.0, 48.0] + + +def test_gps_timeline_covers_every_segment_timer_window() -> None: + """Start anchors prevent Strava from dropping each run's first sample window.""" + messages, segments = _emit_stream() + records = [m for m in messages if type(m).__name__ == "RecordMessage"] + for segment in segments: + segment_records = [ + record + for record in records + if segment.start_time_ms <= record.timestamp <= segment.end_time_ms + ] + assert segment_records[0].timestamp == segment.start_time_ms + assert segment_records[-1].timestamp == segment.end_time_ms + assert ( + segment_records[-1].timestamp - segment_records[0].timestamp + == segment.timer_time_ms + ) + + +def test_inactive_tail_excluded_even_with_stale_speed() -> None: + """The activity flag excludes stale non-zero speed after paddling stops.""" + base = 1_700_000_000_000 + rows = [] + d = 0.0 + for i in range(10): # moving: distance advances + d += 3.0 + rows.append({"timestamp": base + (i + 1) * 1000, + "session_elapsed": i + 1, "distance": d, "speed_instant": 3.0, + "active": 1, "sample_duration": 1}) + for i in range(10, 13): # frozen distance, speed still non-zero, sub-threshold + rows.append({"timestamp": base + (i + 1) * 1000, + "session_elapsed": i + 1, "distance": d, "speed_instant": 1.2, + "active": 0, "sample_duration": 1}) + df = Table( + ["timestamp", "session_elapsed", "distance", "speed_instant", "active", "sample_duration"], + rows, + ) + + exporter = _make_exporter() + builder = RecordingBuilder() + segments = exporter._add_records(builder=builder, workout_df=df) + + last_moving_ts = base + 10 * 1000 + assert segments[-1].end_time_ms == last_moving_ts, segments[-1] + record_ts = [ + m.timestamp for m in builder.messages if type(m).__name__ == "RecordMessage" + ] + assert record_ts and max(record_ts) == last_moving_ts, max(record_ts) + stops = [ + m + for m in builder.messages + if type(m).__name__ == "EventMessage" + and m.event_type == EventType.STOP_ALL.value + ] + assert stops and stops[-1].timestamp == last_moving_ts + print(" ok: frozen-distance tail excluded from records, laps and final STOP_ALL") diff --git a/tests/test_history_window.py b/tests/test_history_window.py new file mode 100644 index 0000000..f891c6b --- /dev/null +++ b/tests/test_history_window.py @@ -0,0 +1,123 @@ +"""Workout-history classification tests for the strict v1 contract.""" + +import json +import stat +import tempfile +from pathlib import Path + +from app.workout_paths import WorkoutPaths +from gui.history_window import _display_name, _workout_type, list_workouts +from gui.recording_lifecycle import _remove_workout_directory, _retry_readonly_removal + + +def _write_metadata(csv_path: Path, mode: str) -> None: + processing_config = { + "pull_length_m": 0.600, + "max_hr": 185, + "hr_zone_mode": "auto", + "hr_zones": [], + } + WorkoutPaths.from_csv(csv_path).metadata.write_text( + json.dumps({ + "version": 1, + "workout_mode": mode, + "processing_config": processing_config, + }), + encoding="utf-8", + ) + + +def test_v1_free_csv_is_free() -> None: + with tempfile.TemporaryDirectory() as tmp: + csv_path = Path(tmp) / "free.csv" + csv_path.touch() + _write_metadata(csv_path, "free") + assert _workout_type(csv_path) == "free" + + +def test_v1_planned_csv_is_planned() -> None: + with tempfile.TemporaryDirectory() as tmp: + csv_path = Path(tmp) / "planned.csv" + csv_path.touch() + _write_metadata(csv_path, "planned") + assert _workout_type(csv_path) == "planned" + + +def test_missing_metadata_is_invalid() -> None: + with tempfile.TemporaryDirectory() as tmp: + csv_path = Path(tmp) / "missing.csv" + csv_path.touch() + assert _workout_type(csv_path) == "invalid" + + +def test_invalid_metadata_is_invalid() -> None: + with tempfile.TemporaryDirectory() as tmp: + csv_path = Path(tmp) / "invalid.csv" + csv_path.touch() + _write_metadata(csv_path, "other") + assert _workout_type(csv_path) == "invalid" + + +def test_fit_file_does_not_claim_free_or_planned() -> None: + assert _workout_type(Path("external.FIT")) == "fit" + + +def test_list_workouts_stops_at_newest_years() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + old = root / "2024" + new = root / "2026" + old.mkdir() + new.mkdir() + for parent, label in ((old, "old"), (new, "new")): + for index in range(3): + workout_dir = parent / f"workout_{label}_{index}" + workout_dir.mkdir() + WorkoutPaths(workout_dir).csv.touch() + found = list_workouts(root, limit=2) + assert len(found) == 2 + assert all(path.parent.parent == new for path in found) + + +def test_list_workouts_default_cap_keeps_history_bounded() -> None: + with tempfile.TemporaryDirectory() as tmp: + year = Path(tmp) / "2026" + year.mkdir() + for index in range(201): + workout_dir = year / f"workout_{index:03d}" + workout_dir.mkdir() + WorkoutPaths(workout_dir).csv.touch() + assert len(list_workouts(year.parent)) == 200 + + +def test_display_name_uses_workout_directory_for_canonical_csv() -> None: + csv_path = Path("2026/workout_20260714_120000/workout.csv") + assert _display_name(csv_path) == "workout_20260714_120000" + + +def test_remove_workout_directory_handles_readonly_csv() -> None: + with tempfile.TemporaryDirectory() as tmp: + workout_directory = Path(tmp) / "workout_20260714_120000" + workout_directory.mkdir() + csv_path = WorkoutPaths(workout_directory).csv + csv_path.write_text("timestamp\n", encoding="utf-8") + csv_path.chmod(stat.S_IREAD) + + _remove_workout_directory(workout_directory) + + assert not workout_directory.exists() + + +def test_readonly_removal_retry_restores_write_permission() -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "readonly.csv" + path.write_text("timestamp\n", encoding="utf-8") + path.chmod(stat.S_IREAD) + + def remove(candidate: str) -> None: + assert Path(candidate).stat().st_mode & stat.S_IWRITE + Path(candidate).unlink() + + _retry_readonly_removal(remove, str(path), PermissionError("read-only")) + + assert not path.exists() diff --git a/tests/test_hr_zones.py b/tests/test_hr_zones.py new file mode 100644 index 0000000..e037772 --- /dev/null +++ b/tests/test_hr_zones.py @@ -0,0 +1,74 @@ +"""Tests for heart-rate zone classification (auto and manual).""" + +from app.stats import zone_lower_bounds +from gui import metrics_format as mf + + +def test_auto_bounds_from_max_hr() -> None: + bounds = zone_lower_bounds(max_hr=185) + assert bounds == [0, 111, 130, 148, 166], bounds + print(f" ok: auto bounds for max 185 -> {bounds}") + + +def test_auto_classification() -> None: + cases = {100: 0, 115: 1, 135: 2, 150: 3, 170: 4} + for hr, expected in cases.items(): + z = mf.hr_zone(hr, max_hr=185) + assert z == expected, f"hr {hr} -> Z{z + 1}, expected Z{expected + 1}" + print(" ok: auto classification across all five zones") + + +def test_manual_bounds_used() -> None: + manual = [100, 120, 140, 160, 175] + assert zone_lower_bounds(max_hr=185, zones=manual) == manual + # Below the first bound still classifies as Z1. + assert mf.hr_zone(80, max_hr=185, zones=manual) == 0 + assert mf.hr_zone(130, max_hr=185, zones=manual) == 1 + assert mf.hr_zone(178, max_hr=185, zones=manual) == 4 + print(" ok: manual zones classify by explicit bpm bounds") + + +def test_invalid_manual_falls_back_to_auto() -> None: + # Not five values, or not ascending -> ignored in favour of max_hr. + assert zone_lower_bounds(max_hr=185, zones=[100, 120]) == [0, 111, 130, 148, 166] + assert zone_lower_bounds(max_hr=185, zones=[100, 90, 140, 160, 175]) == [ + 0, 111, 130, 148, 166, + ] + print(" ok: invalid manual config falls back to auto") + + +def test_zone_range_and_invalid_hr() -> None: + assert mf.zone_range(2, max_hr=185) == (130, 147) + low, high = mf.zone_range(4, max_hr=185) + assert high is None and low == 166, (low, high) + assert mf.hr_zone(None, max_hr=185) == -1 + assert mf.hr_zone("n/a", max_hr=185) == -1 + print(" ok: zone ranges correct; invalid HR returns -1") + + +def test_target_value_and_status() -> None: + # HR zone target (returned 1-indexed to match Z1..Z5 in plans). + assert mf.target_value("hr_zone", {"heart_rate__bpm": 135}, 185) == 3.0 + assert mf.target_status("hr_zone", 3.0, 3, 3) == "in" + assert mf.target_status("hr_zone", 5.0, 3, 3) == "hard" # zone too high + assert mf.target_status("hr_zone", 1.0, 3, 3) == "easy" # zone too low + # Pace is inverted: fewer seconds = harder effort. + assert mf.target_status("pace_500", 138, 130, 145) == "in" + assert mf.target_status("pace_500", 120, 130, 145) == "hard" + assert mf.target_status("pace_500", 160, 130, 145) == "easy" + # Power / spm: higher = harder. + assert mf.target_status("power", 240, 180, 220) == "hard" + assert mf.target_status("spm", 50, 58, 64) == "easy" + # Missing reading -> None. + assert mf.target_value("hr_zone", {}, 185) is None + assert mf.target_status("power", None, 180, 220) is None + print(" ok: target value + status (incl. inverted pace)") + + +def test_target_text() -> None: + assert mf.target_text({"metric": "hr_zone", "low": 3, "high": 3}) == "Zone 3" + assert mf.target_text({"metric": "hr_zone", "low": 2, "high": 3}) == "Zone 2-3" + assert mf.target_text({"metric": "power", "low": 180, "high": 220}) == "180-220 W" + assert "/500m" in mf.target_text({"metric": "pace_500", "low": 130, "high": 145}) + assert mf.target_text(None) == "" + print(" ok: target text labels") diff --git a/tests/test_metrics_format.py b/tests/test_metrics_format.py new file mode 100644 index 0000000..cb7b375 --- /dev/null +++ b/tests/test_metrics_format.py @@ -0,0 +1,74 @@ +"""Live metric-field policy regression tests.""" + +from gui.dashboard_constants import TILES +from gui.metrics_format import ( + fmt_metres, + fmt_time_precise, + format_metrics, + format_pace, + metric_value, + target_value, +) + + +def _packet() -> dict[str, float]: + return { + "speed__mps": 2.0, + "speed_instant__mps": 4.0, + "cadence__spm": 60.0, + "cadence_instant__spm": 90.0, + "pull_force__n": 100.0, + "pull_force_instant__n": 200.0, + "pace_500m__s": 250.0, + "pace_500m_instant__s": 125.0, + } + + +def test_live_metrics_use_fixed_canonical_fields() -> None: + packet = _packet() + + formatted = format_metrics(packet) + + assert formatted["speed"] == "14.4" + assert formatted["stroke"] == "90" + assert formatted["pull"] == "200" + assert formatted["power"] == "180" + assert metric_value("speed", packet) == 14.4 + + +def test_dashboard_sensor_cards_are_explicitly_live() -> None: + labels = {key: label for key, label, _unit, _stats in TILES} + + assert labels["time"] == "Active time" + assert labels["distance"] == "Active distance" + for key in ("speed", "pace", "stroke", "hr", "pull", "power"): + assert labels[key].startswith("Live ") + + +def test_pace_and_targets_use_fixed_canonical_fields() -> None: + packet = _packet() + + assert format_pace(packet, 500) == "02:05" + assert target_value("spm", packet) == 90.0 + assert target_value("pace_500", packet) == 125.0 + assert target_value("power", packet) == 180.0 + + +def test_live_metrics_do_not_fall_back_to_averaged_fields() -> None: + averaged_only = { + key: value for key, value in _packet().items() if "instant" not in key + } + + assert format_metrics(averaged_only) == {} + assert format_pace(averaged_only, 500) is None + assert metric_value("speed", averaged_only) is None + assert target_value("spm", averaged_only) is None + assert target_value("pace_500", averaged_only) is None + assert target_value("power", averaged_only) is None + + +def test_finalized_split_formatting_preserves_recorded_precision() -> None: + assert fmt_time_precise(0.375) == "00:00.375" + assert fmt_time_precise(16.75) == "00:16.75" + assert fmt_metres(0.75) == "0.75 m" + assert fmt_metres(24.0) == "24 m" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..4cc98b8 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,753 @@ +""" +End-to-end checks for the KayakFit data pipeline. + +Run from the project root on a machine with the dependencies installed: + + python tests/test_pipeline.py + +This avoids any Bluetooth / GUI dependency and exercises: + * CSV -> FIT conversion produces a valid file + * a malformed CSV fails gracefully (no SystemExit / thread kill) + * runtime config validation raises ConfigError instead of sys.exit +""" + +import contextlib +import json +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +# Allow running directly from the project root. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from fit_tool.fit_file import FitFile +from fit_tool.profile.profile_type import Activity, Event, EventType + +from app.export_fit import ( + _GPS_COURSE_END, + _GPS_COURSE_START, + FitExporter, + _synthetic_gps_position, +) +from app.field_mapping import CSV_COLUMNS +from app.read_csv import CsvReader, CsvReadError +from app.summary import compute_summary +from app.table import Table +from app.workout_metadata import load_workout_config, save_workout_metadata +from app.workout_paths import WorkoutPaths + +CONFIG = { + "log_level": "warning", + "pull_length_m": 0.600, + "max_hr": 185, + "hr_zone_mode": "auto", + "hr_zones": [], +} + + +def test_recorded_free_workout_golden_metrics() -> None: + """Verify free-workout timing and distance against a fixed sensor sample set.""" + elapsed_groups = [ + list(range(1, 18)), + [18, 20, 21, 22, 23, 24], + list(range(25, 38)), + [38, 40, 41, 42, 43, 44, 45], + list(range(46, 55)), + [55, 56, 57, 58, 60, 61, 62], + ] + distance_groups = [ + [1, 3, 6, 8, 13, 16, 19, 21, 24, 27, 29, 32, 34, 39, 42, 44, 47], + [47] * 6, + [53, 56, 59, 63, 65, 68, 70, 74, 76, 78, 81, 85, 87], + [87] * 7, + [94, 97, 100, 104, 107, 109, 113, 114, 117], + [117] * 7, + ] + speed_groups = [ + [0.72, 1.19, 1.54, 2.07, 2.77, 2.84, 2.77, 2.80, 2.73, + 2.74, 2.66, 2.72, 2.70, 2.76, 2.81, 2.78, 2.83], + [0.0] * 6, + [2.40, 2.43, 2.48, 2.41, 2.73, 2.69, 2.60, 2.61, 2.65, + 2.56, 2.63, 2.64, 2.65], + [0.0] * 7, + [2.37, 2.34, 2.37, 2.35, 2.68, 2.70, 2.54, 2.51, 2.53], + [0.0] * 7, + ] + active_groups = [1, 0, 1, 0, 1, 0] + rows: list[dict[str, Any]] = [] + previous_end_s = 0 + for elapsed, distances, speeds, active in zip( + elapsed_groups, distance_groups, speed_groups, active_groups, strict=True + ): + for end_s, distance_m, speed_mps in zip( + elapsed, distances, speeds, strict=True + ): + rows.append( + { + "timestamp": 1_784_314_902_544 + end_s * 1000, + "session_elapsed": end_s, + "sample_duration": end_s - previous_end_s, + "heart_rate": 120, + "cadence": 60 if active else 0, + "cadence_instant": 60 if active else 0, + "distance": distance_m, + "speed_instant": speed_mps, + "pull_force": 100 if active else 0, + "pull_force_instant": 100 if active else 0, + "active": active, + } + ) + previous_end_s = end_s + columns = list(rows[0]) + summary = compute_summary(Table(columns, rows), pull_length_m=CONFIG["pull_length_m"]) + + assert summary.active_time_s == 39.0 + assert summary.elapsed_time_s == 62.0 + assert summary.pause_time_s == 23.0 + assert summary.distance_m == 117.0 + assert [lap.start_s for lap in summary.laps] == [0.0, 24.0, 45.0] + assert [lap.active_time_s for lap in summary.laps] == [17.0, 13.0, 9.0] + assert [lap.distance_m for lap in summary.laps] == [47.0, 40.0, 30.0] + assert [round((lap.avg_speed_mps or 0.0) * 3.6, 2) for lap in summary.laps] == [ + 8.77, 9.27, 8.96 + ] + assert round((summary.max_speed_mps or 0.0) * 3.6, 2) == 10.22 + assert summary.series_time_bounds == (0.0, 62.0) + assert sum(lap.active_time_s for lap in summary.laps) == summary.active_time_s + assert sum(lap.distance_m for lap in summary.laps) == summary.distance_m + + +def test_canonical_fields_own_intervals() -> None: + """Explicit session endpoints and durations own the processing clock.""" + rows = [ + { + "timestamp": 1_700_000_000_000 + elapsed * 1000, + "session_elapsed": elapsed, + "heart_rate": 120, + "cadence": 60 if active else 0, + "cadence_instant": 60 if active else 0, + "distance": distance, + "speed_instant": 2.0 if active else 0.0, + "pull_force": 100 if active else 0, + "pull_force_instant": 100 if active else 0, + "active": active, + "sample_duration": elapsed - (0 if index == 0 else (2, 5, 6)[index - 1]), + } + for index, (elapsed, active, distance) in enumerate( + ((2, 0, 0.0), (5, 1, 6.0), (6, 1, 8.0)) + ) + ] + + summary = compute_summary( + Table(list(rows[0]), rows), pull_length_m=CONFIG["pull_length_m"] + ) + + assert summary.elapsed_time_s == 6.0 + assert summary.active_time_s == 4.0 + assert summary.pause_time_s == 2.0 + assert summary.series_t == [5.0, 6.0] + assert summary.series_time_bounds == (0.0, 6.0) + assert summary.laps[0].start_s == 2.0 + assert summary.laps[0].elapsed_time_s == 4.0 + assert summary.laps[0].distance_m == summary.distance_m == 8.0 + + +def _row(i: int) -> dict[str, Any]: + """Build one synthetic data record (moving, increasing distance).""" + base_ts = 1_700_000_000_000 # fixed ms epoch for determinism + return { + "timestamp": base_ts + i * 1000, + "session_elapsed__s": i + 1, + "sample_duration__s": 1, + "heart_rate__bpm": 120 + (i % 20), + "kayakfirst_timestamp": base_ts + i * 1000, + "col_2": 0, "col_3": 0, + "col_4": "0.00", "col_5": "0.00", + "col_6": 0, "active_paddling": 1, "col_8": 0, + "distance__m": f"{i * 3.2:.2f}", + "speed__mps": "3.20", + "speed_instant__mps": "3.20", + "cadence__spm": 80, "cadence_instant__spm": 80, + "pace_200m__s": 62, "pace_200m_instant__s": 62, + "pace_500m__s": 156, "pace_500m_instant__s": 156, + "pace_1000m__s": 312, "pace_1000m_instant__s": 312, + "pull_force__n": 200, "pull_force_instant__n": 200, + "elapsed_time__s": i + 1, + "window_size__s": 1, + } + + +def _write_csv(path: Path, rows: int, header: bool = True, full_schema: bool = True) -> None: + fields = CSV_COLUMNS if full_schema else CSV_COLUMNS[:5] + lines = [] + if header: + lines.append(";".join(fields)) + for i in range(rows): + record = _row(i) + lines.append(";".join(str(record[f]) for f in fields)) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + save_workout_metadata(path, CONFIG, "free") + + +def test_csv_to_fit_produces_file() -> None: + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "workout.csv" + _write_csv(csv_path, rows=120) + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert result["success"], f"conversion failed: {result.get('message')}" + fit_path = Path(result["file_path"]) + assert fit_path.exists(), "FIT file was not created" + assert fit_path.stat().st_size > 200, "FIT file is suspiciously small" + print(f" ok: produced {fit_path.name} ({fit_path.stat().st_size} bytes)") + + +def _fit_data_messages(path: Path, message: str) -> list[dict[str, Any]]: + """Decode fit_tool row output into dictionaries for one message type.""" + decoded: list[dict[str, Any]] = [] + for row in FitFile.from_file(str(path)).to_rows(): + if len(row) < 3 or row[0] != "Data" or row[2] != message: + continue + decoded.append({row[i]: row[i + 1] for i in range(3, len(row), 3)}) + return decoded + + +def test_synthetic_gps_position_follows_out_and_back_course() -> None: + """Each 2 km leg alternates between the fixed course endpoints.""" + assert _synthetic_gps_position(0.0) == _GPS_COURSE_START + assert _synthetic_gps_position(2000.0) == _GPS_COURSE_END + assert _synthetic_gps_position(4000.0) == _GPS_COURSE_START + assert _synthetic_gps_position(6000.0) == _GPS_COURSE_END + + +def test_decoded_fit_exactly_matches_summary() -> None: + """Compare final encoded FIT session/laps with the rendered summary model.""" + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "paused.csv" + rows = [_row(i) for i in range(10)] + # Raw cumulative distance advances during the inactive period. The + # finalized FIT record domain must rebase it so those ten metres do not + # leak into the session, chart, or downstream platform totals. + distances = [10.0, 13.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 29.0, 32.0] + rows[0]["sample_duration__s"] = 1 + for i, row in enumerate(rows): + row["distance__m"] = f"{distances[i]:.2f}" + if 3 <= i <= 7: + row["active_paddling"] = 0 + row["speed__mps"] = row["speed_instant__mps"] = "0.00" + row["cadence__spm"] = row["cadence_instant__spm"] = 0 + row["pull_force__n"] = row["pull_force_instant__n"] = 0 + row["heart_rate__bpm"] = 200 + _write_cols = [";".join(CSV_COLUMNS)] + _write_cols.extend( + ";".join(str(row[field]) for field in CSV_COLUMNS) for row in rows + ) + csv_path.write_text("\n".join(_write_cols) + "\n", encoding="utf-8") + save_workout_metadata(csv_path, CONFIG, "free") + + table = CsvReader(str(csv_path)).read_all() + summary = compute_summary(table, pull_length_m=CONFIG["pull_length_m"]) + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert result["success"], result + fit_path = Path(result["file_path"]) + session = _fit_data_messages(fit_path, "session")[0] + activity = _fit_data_messages(fit_path, "activity")[0] + laps = _fit_data_messages(fit_path, "lap") + records = _fit_data_messages(fit_path, "record") + sensor_records = [record for record in records if "heart_rate" in record] + anchors = [record for record in records if "heart_rate" not in record] + active_rows = [row for row in rows if row["active_paddling"] == 1] + origin_ms = int(rows[0]["timestamp"]) - round( + float(rows[0]["session_elapsed__s"]) * 1000 + ) + + assert session["total_distance"] == summary.distance_m == 22.0 + assert session["total_timer_time"] == summary.active_time_s == 5.0 + assert session["total_elapsed_time"] == summary.elapsed_time_s == 10.0 + assert summary.pause_time_s == 5.0 + assert sum( + lap.elapsed_time_s + lap.pause_after_s for lap in summary.laps + ) == summary.elapsed_time_s + assert activity["total_timer_time"] == summary.active_time_s + assert activity["num_sessions"] == 1 + assert activity["type"] == Activity.MANUAL.value + assert activity["event"] == Event.ACTIVITY.value + assert activity["event_type"] == EventType.STOP.value + assert session["num_laps"] == len(summary.laps) == 2 + assert session["avg_heart_rate"] == summary.avg_hr + assert session["avg_cadence"] == summary.avg_spm + assert session["avg_power"] == summary.avg_power + assert session["max_heart_rate"] == summary.max_hr + assert session["max_power"] == summary.max_power + assert session["max_speed"] == summary.max_speed_mps + assert abs(session["avg_speed"] - (summary.avg_speed_mps or 0.0)) < 0.001 + assert [record["timestamp"] for record in sensor_records] == [ + row["timestamp"] for row in active_rows + ] + assert summary.series_t == [ + (int(row["timestamp"]) - origin_ms) / 1000.0 for row in active_rows + ] + assert [record["distance"] for record in sensor_records] == summary.series_distance + assert records[-1]["distance"] == session["total_distance"] == 22.0 + assert all("position_lat" in record for record in records) + assert all("position_long" in record for record in records) + assert [record["distance"] for record in anchors] == [0.0, 16.0] + + anchor_indices = [ + index for index, record in enumerate(records) if "heart_rate" not in record + ] + gps_spans = [] + for ordinal, start_index in enumerate(anchor_indices): + end_index = ( + anchor_indices[ordinal + 1] - 1 + if ordinal + 1 < len(anchor_indices) + else len(records) - 1 + ) + gps_spans.append( + (records[end_index]["timestamp"] - records[start_index]["timestamp"]) + / 1000.0 + ) + assert gps_spans == [3.0, 2.0] + assert sum(gps_spans) == session["total_timer_time"] + + # GPS is derived from the rebased active-only distance, not the raw + # odometer, whose inactive jump would place the final point at 32 m. + for record in records: + expected_latitude, expected_longitude = _synthetic_gps_position( + record["distance"] + ) + assert abs(record["position_lat"] - expected_latitude) < 1e-6 + assert abs(record["position_long"] - expected_longitude) < 1e-6 + + assert [lap["total_distance"] for lap in laps] == [ + item.distance_m for item in summary.laps + ] + assert [lap["total_timer_time"] for lap in laps] == [ + item.active_time_s for item in summary.laps + ] + assert [lap["avg_heart_rate"] for lap in laps] == [ + item.avg_hr for item in summary.laps + ] + assert [lap.get("avg_speed") for lap in laps] == [ + item.avg_speed_mps for item in summary.laps + ] + assert [lap.get("max_speed") for lap in laps] == [ + item.max_speed_mps for item in summary.laps + ] + assert summary.series_t[0] == 1.0 + assert summary.series_breaks == [True, False, False, True, False] + + +def test_speed_statistics_and_fit_records_use_instantaneous_sensor() -> None: + """Summary, graph and FIT all retain the recorded speed channel.""" + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "workout.csv" + config = dict(CONFIG) + rows = [] + cumulative = 0.0 + for i in range(10): + cumulative += 2.0 if i < 5 else 4.0 + row = _row(i) + row["distance__m"] = f"{cumulative:.2f}" + row["speed__mps"] = "3.00" + row["speed_instant__mps"] = "9.00" if i == 7 else "3.00" + rows.append(row) + lines = [";".join(CSV_COLUMNS)] + lines.extend(";".join(str(row[field]) for field in CSV_COLUMNS) for row in rows) + csv_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + save_workout_metadata(csv_path, config, "free") + + table = CsvReader(str(csv_path)).read_all() + summary = compute_summary(table, pull_length_m=config["pull_length_m"]) + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert result["success"], result + + session = _fit_data_messages(Path(result["file_path"]), "session")[0] + laps = _fit_data_messages(Path(result["file_path"]), "lap") + records = _fit_data_messages(Path(result["file_path"]), "record") + sensor_speeds = [record["speed"] for record in records if "speed" in record] + + assert summary.avg_speed_mps == 3.6 + assert summary.max_speed_mps == 9.0 + assert max(sensor_speeds) == summary.max_speed_mps + assert sensor_speeds == [3.0] * 7 + [9.0] + [3.0] * 2 + + # Summary and FIT session agree, and the FIT file is self-consistent. + assert session["avg_speed"] == summary.avg_speed_mps + assert session["max_speed"] == summary.max_speed_mps + assert max(sensor_speeds) <= session["max_speed"] + assert all(lap["max_speed"] >= lap["avg_speed"] for lap in laps) + assert session["max_speed"] >= session["avg_speed"] + assert session["max_speed"] >= max(lap["max_speed"] for lap in laps) + + +def test_instant_metrics_and_power_use_one_source_policy() -> None: + """Summary/FIT cadence, pull force, and power use instant points.""" + with tempfile.TemporaryDirectory() as directory: + csv_path = Path(directory) / "metric-sources.csv" + rows = [] + for i, instant_cadence in enumerate((70, 90, 110)): + row = _row(i) + row["cadence__spm"] = 60 + row["cadence_instant__spm"] = instant_cadence + row["pull_force__n"] = 100 + row["pull_force_instant__n"] = 200 + i * 50 + rows.append(row) + lines = [";".join(CSV_COLUMNS)] + lines.extend( + ";".join(str(row[field]) for field in CSV_COLUMNS) for row in rows + ) + csv_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + save_workout_metadata(csv_path, CONFIG, "free") + + table = CsvReader(str(csv_path)).read_all() + summary = compute_summary(table, pull_length_m=CONFIG["pull_length_m"]) + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert result["success"], result + + records = _fit_data_messages(Path(result["file_path"]), "record") + sensor_records = [record for record in records if "cadence" in record] + session = _fit_data_messages(Path(result["file_path"]), "session")[0] + + assert summary.series_spm == [70.0, 90.0, 110.0] + assert summary.series_pull == [200.0, 250.0, 300.0] + assert summary.avg_spm == 90.0 + assert [record["cadence"] for record in sensor_records] == [70, 90, 110] + # Instantaneous pairs produce 140 W, 225 W, and 330 W. + assert summary.series_power == [140.0, 225.0, 330.0] + assert summary.avg_power == 232.0 + assert summary.max_power == 330 + assert [record["power"] for record in sensor_records] == [140, 225, 330] + assert session["avg_cadence"] == 90 + assert session["avg_power"] == 232 + assert session["max_power"] == 330 + + +def test_resumed_distance_jump_is_preserved_consistently() -> None: + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "resume-jump.csv" + rows = [_row(i) for i in range(6)] + distances = [3.0, 6.0, 6.0, 6.0, 18.0, 21.0] + for i, row in enumerate(rows): + row["distance__m"] = f"{distances[i]:.2f}" + row["speed__mps"] = row["speed_instant__mps"] = "3.00" + if i in (2, 3): + row["active_paddling"] = 0 + row["speed__mps"] = row["speed_instant__mps"] = "0.00" + row["cadence__spm"] = row["cadence_instant__spm"] = 0 + row["pull_force__n"] = row["pull_force_instant__n"] = 0 + if i >= 4: + row["timestamp"] = int(row["timestamp"]) + 6000 + row["session_elapsed__s"] = int(row["session_elapsed__s"]) + 6 + lines = [";".join(CSV_COLUMNS)] + lines.extend(";".join(str(row[field]) for field in CSV_COLUMNS) for row in rows) + csv_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + save_workout_metadata(csv_path, CONFIG, "free") + + table = CsvReader(str(csv_path)).read_all() + summary = compute_summary(table, pull_length_m=CONFIG["pull_length_m"]) + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert result["success"], result + fit_path = Path(result["file_path"]) + laps = _fit_data_messages(fit_path, "lap") + records = _fit_data_messages(fit_path, "record") + sensor_records = [record for record in records if "heart_rate" in record] + anchors = [record for record in records if "heart_rate" not in record] + + assert [item.distance_m for item in summary.laps] == [6.0, 15.0] + assert [item.avg_speed_mps for item in summary.laps] == [3.0, 3.0] + assert [lap["total_distance"] for lap in laps] == [6.0, 15.0] + assert [lap["avg_speed"] for lap in laps] == [3.0, 3.0] + assert [record["distance"] for record in sensor_records] == [3.0, 6.0, 18.0, 21.0] + assert [record["distance"] for record in anchors] == [0.0, 6.0] + assert all("position_lat" in record for record in records) + assert all("position_long" in record for record in records) + + +def test_active_signal_excludes_heart_rate_prefix_and_tail() -> None: + """Only active-paddling sample windows may become FIT/summary records.""" + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "hr-tail.csv" + rows = [_row(i) for i in range(7)] + actives = [0, 0, 1, 1, 1, 0, 0] + distances = [0.0, 0.0, 0.0, 3.0, 6.0, 6.0, 6.0] + heart_rates = [90, 95, 120, 125, 130, 180, 175] + for i, row in enumerate(rows): + row["active_paddling"] = actives[i] + row["distance__m"] = f"{distances[i]:.2f}" + row["heart_rate__bpm"] = heart_rates[i] + if not actives[i]: + row["speed__mps"] = row["speed_instant__mps"] = "0.00" + row["cadence__spm"] = row["cadence_instant__spm"] = 0 + row["pull_force__n"] = row["pull_force_instant__n"] = 0 + # Raw device timing is diagnostic and cannot change canonical timing. + rows[3]["window_size__s"] = 2 + lines = [";".join(CSV_COLUMNS)] + lines.extend(";".join(str(row[field]) for field in CSV_COLUMNS) for row in rows) + csv_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + save_workout_metadata(csv_path, CONFIG, "free") + + table = CsvReader(str(csv_path)).read_all() + summary = compute_summary(table, pull_length_m=CONFIG["pull_length_m"]) + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert result["success"], result + fit_path = Path(result["file_path"]) + session = _fit_data_messages(fit_path, "session")[0] + records = _fit_data_messages(fit_path, "record") + sensor_records = [record for record in records if "heart_rate" in record] + + assert summary.active_time_s == session["total_timer_time"] == 3.0 + assert summary.elapsed_time_s == session["total_elapsed_time"] == 7.0 + assert summary.series_hr == [120.0, 125.0, 130.0] + assert summary.series_t[0] == 3.0 + assert summary.series_breaks == [True, False, False] + assert summary.max_hr == session["max_heart_rate"] == 130 + assert [record["heart_rate"] for record in sensor_records] == [120, 125, 130] + + +def test_decoded_planned_laps_match_active_only_step_summary() -> None: + """Planned FIT laps retain empty rest and exactly mirror step statistics.""" + with tempfile.TemporaryDirectory() as d: + fit_path = Path(d) / "planned.fit" + rows = [_row(i) for i in range(15)] + distances = [3.0 * (i + 1) if i < 5 else 15.0 for i in range(15)] + for i in range(10, 15): + distances[i] = 18.0 + 3.0 * (i - 10) + for i, row in enumerate(rows): + row["distance__m"] = f"{distances[i]:.2f}" + if 5 <= i < 10: + row["active_paddling"] = 0 + row["heart_rate__bpm"] = 200 + row["speed__mps"] = row["speed_instant__mps"] = "0.00" + row["cadence__spm"] = row["cadence_instant__spm"] = 0 + row["pull_force__n"] = row["pull_force_instant__n"] = 0 + csv_path = Path(d) / "planned.csv" + lines = [";".join(CSV_COLUMNS)] + lines.extend(";".join(str(row[field]) for field in CSV_COLUMNS) for row in rows) + csv_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + table = CsvReader(str(csv_path)).read_all() + steps = [ + { + "index": 0, + "type": "work", + "label": "Work 1", + "start_elapsed_s": 0, + "end_elapsed_s": 5, + }, + { + "index": 1, + "type": "rest", + "label": "Rest", + "start_elapsed_s": 5, + "end_elapsed_s": 10, + }, + { + "index": 2, + "type": "work", + "label": "Work 2", + "start_elapsed_s": 10, + "end_elapsed_s": 15, + }, + ] + summary = compute_summary( + table, program_steps=steps, pull_length_m=CONFIG["pull_length_m"] + ) + assert FitExporter(CONFIG["pull_length_m"]).export( + table, str(fit_path), program_steps=steps + ) + laps = _fit_data_messages(fit_path, "lap") + + assert len(laps) == len(summary.laps) == 3 + assert [lap["total_timer_time"] for lap in laps] == [ + item.active_time_s for item in summary.laps + ] + assert [lap["total_elapsed_time"] for lap in laps] == [ + item.elapsed_time_s for item in summary.laps + ] + assert [lap["total_distance"] for lap in laps] == [ + item.distance_m for item in summary.laps + ] + assert [lap.get("avg_heart_rate") for lap in laps] == [ + item.avg_hr for item in summary.laps + ] + assert [lap.get("avg_speed") for lap in laps] == [ + item.avg_speed_mps for item in summary.laps + ] + assert summary.laps[1].active_time_s == 0.0 + assert summary.laps[1].avg_hr is None + assert summary.max_hr != 200 + + +def test_export_accepts_a_workout_longer_than_72_minutes() -> None: + """FIT duration fields accept seconds, so long sessions cannot overflow.""" + base = 1_700_000_000_000 + rows = [ + { + "timestamp": base + 4_320_000, + "session_elapsed": 4_320, + "heart_rate": 140, + "cadence": 70, + "cadence_instant": 70, + "distance": 12_960.0, + "speed_instant": 3.0, + "pull_force": 180, + "pull_force_instant": 180, + "active": 1, + "sample_duration": 4_320, + }, + { + "timestamp": base + 4_321_000, + "session_elapsed": 4_321, + "heart_rate": 140, + "cadence": 70, + "cadence_instant": 70, + "distance": 12_963.0, + "speed_instant": 3.0, + "pull_force": 180, + "pull_force_instant": 180, + "active": 1, + "sample_duration": 1, + }, + ] + table = Table( + [ + "timestamp", + "session_elapsed", + "heart_rate", + "cadence", + "cadence_instant", + "distance", + "speed_instant", + "pull_force", + "pull_force_instant", + "active", + "sample_duration", + ], + rows, + ) + + with tempfile.TemporaryDirectory() as directory: + fit_path = Path(directory) / "long.fit" + assert FitExporter(CONFIG["pull_length_m"]).export(table, str(fit_path)) + session = _fit_data_messages(fit_path, "session")[0] + activity = _fit_data_messages(fit_path, "activity")[0] + laps = _fit_data_messages(fit_path, "lap") + + assert session["total_timer_time"] == 4_321.0 + assert session["total_elapsed_time"] == 4_321.0 + assert activity["total_timer_time"] == 4_321.0 + assert [lap["total_timer_time"] for lap in laps] == [4_321.0] + + +def test_export_uses_recorded_processing_config() -> None: + """FIT generation reads power calibration from recorded metadata.""" + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "snapshot.csv" + _write_csv(csv_path, rows=10) + recorded = {**CONFIG, "pull_length_m": 0.8} + save_workout_metadata(csv_path, recorded, "free") + + assert load_workout_config(csv_path)["pull_length_m"] == 0.8 + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert result["success"], result + session = _fit_data_messages(Path(result["file_path"]), "session")[0] + # 200 N * 0.8 m * 80 spm / 60 = 213.33 -> estimate_power rounds to 213 W. + assert session["avg_power"] == 213 + + +def test_metadata_rejects_removed_processing_keys() -> None: + with tempfile.TemporaryDirectory() as directory: + csv_path = Path(directory) / "strict.csv" + save_workout_metadata(csv_path, CONFIG, "free") + metadata_path = WorkoutPaths.from_csv(csv_path).metadata + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + payload["processing_config"]["data_points"] = "instant" + metadata_path.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ValueError, match="schema mismatch"): + load_workout_config(csv_path) + + +def test_failed_export_preserves_existing_fit() -> None: + """A failed replacement must not delete the last valid FIT file.""" + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "atomic.csv" + fit_path = WorkoutPaths.from_csv(csv_path).fit + _write_csv(csv_path, rows=10) + fit_path.write_bytes(b"previous-valid-fit") + table = CsvReader(str(csv_path)).read_all() + + class _FailingFit: + def to_file(self, path: str) -> None: + raise OSError("simulated full disk") + + class _Builder: + def __init__(self, **_kwargs: Any) -> None: + pass + + def add(self, message: Any = None) -> None: + pass + + def build(self) -> _FailingFit: + return _FailingFit() + + with patch("app.export_fit.FitFileBuilder", _Builder): + success = FitExporter(CONFIG["pull_length_m"]).export( + table, + str(fit_path), + ) + assert not success + assert fit_path.read_bytes() == b"previous-valid-fit" + + +def test_stationary_recording_does_not_become_activity() -> None: + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "stationary.csv" + rows = [_row(i) for i in range(10)] + for row in rows: + row["distance__m"] = "0.00" + row["speed__mps"] = row["speed_instant__mps"] = "0.00" + row["active_paddling"] = 0 + lines = [";".join(CSV_COLUMNS)] + lines.extend(";".join(str(row[field]) for field in CSV_COLUMNS) for row in rows) + csv_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert not result["success"] + assert not WorkoutPaths.from_csv(csv_path).fit.exists() + + +def test_malformed_csv_fails_gracefully() -> None: + with tempfile.TemporaryDirectory() as d: + csv_path = Path(d) / "bad.csv" + _write_csv(csv_path, rows=10, full_schema=False) # missing columns + # Direct reader call should raise the typed error (not SystemExit). + try: + CsvReader(str(csv_path)).read_all() + raised = False + except CsvReadError: + raised = True + except SystemExit as err: + raise AssertionError("read_all raised SystemExit instead of CsvReadError") from err + assert raised, "expected CsvReadError for malformed CSV" + + # And the public conversion API turns it into a clean failure result. + result = FitExporter.convert_csv_to_fit(str(csv_path)) + assert not result["success"] + print(f" ok: malformed CSV handled -> {result.get('message')!r}") + + +def test_runtime_config_validation() -> None: + from gui.config_manager import ConfigError, ConfigManager + + mgr = ConfigManager.__new__(ConfigManager) # bypass home-dir side effects + mgr.config_file = Path(tempfile.gettempdir()) / "kayakfit_nonexistent.yml" + with contextlib.suppress(OSError): + mgr.config_file.unlink() + try: + mgr.load_runtime_config() + raise AssertionError("expected ConfigError when no ergometer is configured") + except ConfigError as e: + print(f" ok: runtime config rejected -> {e}") diff --git a/tests/test_polling.py b/tests/test_polling.py new file mode 100644 index 0000000..255193b --- /dev/null +++ b/tests/test_polling.py @@ -0,0 +1,92 @@ +"""Fixed-rate KayakFirst polling contracts.""" + +import asyncio +from unittest.mock import patch + +from app.kayakfirst_ergometer_bluetooth import ( + POLL_DATA_CMD, + KayakFirstErgometer, +) + + +class _Clock: + def __init__(self) -> None: + self.now = 0.0 + self.sleeps: list[float] = [] + + def time(self) -> float: + return self.now + + async def sleep(self, delay: float) -> None: + self.sleeps.append(delay) + self.now += delay + + +class _Client: + is_connected = True + + +class _Logger: + def warning(self, msg: str = "") -> None: + pass + + +def _ergometer( + clock: _Clock, send_duration_s: float +) -> tuple[KayakFirstErgometer, list[float]]: + ergometer = KayakFirstErgometer.__new__(KayakFirstErgometer) + ergometer._should_run = True + ergometer.client = _Client() # type: ignore[assignment] + ergometer.logger = _Logger() # type: ignore[assignment] + starts: list[float] = [] + + async def _send(command: str) -> None: + assert command == POLL_DATA_CMD + starts.append(clock.time()) + clock.now += send_duration_s + if len(starts) == 4: + ergometer._should_run = False + + ergometer._send_command = _send # type: ignore[method-assign] + return ergometer, starts + + +def test_poll_writes_stay_on_fixed_one_second_deadlines() -> None: + clock = _Clock() + ergometer, starts = _ergometer(clock, send_duration_s=0.05) + + with ( + patch( + "app.kayakfirst_ergometer_bluetooth.asyncio.get_running_loop", + return_value=clock, + ), + patch( + "app.kayakfirst_ergometer_bluetooth.asyncio.sleep", + side_effect=clock.sleep, + ), + ): + asyncio.run(ergometer.poll_data()) + + assert starts == [0.0, 1.0, 2.0, 3.0] + assert len(clock.sleeps) == 3 + assert all(abs(delay - 0.95) < 1e-9 for delay in clock.sleeps) + + +def test_late_poll_resumes_without_catch_up_burst() -> None: + clock = _Clock() + ergometer, starts = _ergometer(clock, send_duration_s=1.25) + + with ( + patch( + "app.kayakfirst_ergometer_bluetooth.asyncio.get_running_loop", + return_value=clock, + ), + patch( + "app.kayakfirst_ergometer_bluetooth.asyncio.sleep", + side_effect=clock.sleep, + ), + ): + asyncio.run(ergometer.poll_data()) + + assert starts == [0.0, 2.25, 4.5, 6.75] + assert clock.sleeps == [1.0, 1.0, 1.0] diff --git a/tests/test_program.py b/tests/test_program.py new file mode 100644 index 0000000..b658502 --- /dev/null +++ b/tests/test_program.py @@ -0,0 +1,444 @@ +""" +Tests for the training-program model and the online ProgramRunner. + +Both modules are stdlib-only (``app/__init__.py`` deliberately imports +nothing), so plain imports work headless — no stubbing needed. +""" + +import contextlib +import logging +import sys +import tempfile +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from app import program, program_runner # noqa: E402 + +ProgramRunner = program_runner.ProgramRunner + + +def test_flatten_repeat_blocks() -> None: + spec = { + "name": "test", + "steps": [ + {"type": "warmup", "duration": {"kind": "time", "seconds": 300}}, + {"repeat": 4, "steps": [ + {"type": "work", "duration": {"kind": "distance", "meters": 500}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 60}}, + ]}, + {"type": "cooldown", "duration": {"kind": "time", "seconds": 300}}, + ], + } + prog = program.load_program(spec) + # warmup + 4*(work+rest) + cooldown = 10 + assert len(prog) == 10, f"expected 10 steps, got {len(prog)}" + assert prog.steps[1].duration_kind == "distance" + assert prog.steps[1].duration_value == 500 + print(f" ok: repeat blocks flattened to {len(prog)} steps") + + +def test_builtins_load() -> None: + progs = program.builtin_programs() + assert len(progs) >= 3 + assert all(len(p) > 0 for p in progs) + print(f" ok: {len(progs)} built-in programs load") + + +def test_install_examples_once() -> None: + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + dest = Path(tmp) / "programs" + n = program.install_examples(dest) + assert n >= 3, f"expected examples copied, got {n}" + assert (dest / program._EXAMPLES_MARKER).exists() + # Second run is a no-op, even after the user deletes an example. + next(dest.glob("*.json")).unlink() + assert program.install_examples(dest) == 0, "must not re-install" + # Existing files are never overwritten when forced by a fresh marker. + (dest / program._EXAMPLES_MARKER).unlink() + kept = next(dest.glob("*.json")) + kept.write_text('{"steps": []}', encoding="utf-8") + program.install_examples(dest) + assert kept.read_text(encoding="utf-8") == '{"steps": []}' + print(f" ok: {n} examples installed once, no overwrite / re-install") + + +def test_time_and_distance_progression() -> None: + spec = {"name": "t", "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 5}}, + {"type": "work", "duration": {"kind": "distance", "meters": 20}}, + ]} + events = [] + r = ProgramRunner(program.load_program(spec), on_event=lambda e: events.append(e["transition"])) + # t=0..4s, distance flat -> still on step 1 + for t in range(0, 5): + r.update(elapsed_s=t, distance_m=0, sample_duration_s=0 if t == 0 else 1) + assert r.index == 0 and not r.done + # t=5s -> step 1 (time) completes, step 2 (distance) starts + r.update(elapsed_s=5, distance_m=0, sample_duration_s=1) + assert r.index == 1, f"expected step 2 active, got index {r.index}" + # advance distance to 20 -> program completes + r.update(elapsed_s=6, distance_m=20, sample_duration_s=1) + assert r.done, "program should be complete after distance step" + assert events[-1] == "program_complete" + assert events.count("step_start") == 2 + print(f" ok: time->distance progression, transitions {events}") + + +def test_open_step_needs_advance() -> None: + spec = {"name": "o", "steps": [ + {"type": "work", "duration": {"kind": "open"}}, + {"type": "cooldown", "duration": {"kind": "time", "seconds": 5}}, + ]} + r = ProgramRunner(program.load_program(spec)) + for t in range(0, 100): # lots of time passes; open step never auto-completes + r.update(elapsed_s=t, distance_m=t, sample_duration_s=0 if t == 0 else 1) + assert r.index == 0 and not r.done, "open step must not auto-complete" + r.advance() # manual lap/skip + assert r.index == 1, "advance should move to next step" + print(" ok: open step waits for manual advance") + + +def test_large_sample_jump_preserves_all_exact_step_boundaries() -> None: + spec = { + "name": "jump", + "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 5}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 5}}, + {"type": "work", "duration": {"kind": "time", "seconds": 5}}, + ], + } + runner = ProgramRunner(program.load_program(spec)) + runner.update(elapsed_s=0, distance_m=0, sample_duration_s=0) + state = runner.update(elapsed_s=20, distance_m=0, sample_duration_s=0) + assert runner.done + assert state["transition"] == "program_complete" + transitions = state["transitions"] + assert [item["transition"] for item in transitions] == [ + "step_start", + "step_start", + "program_complete", + ] + assert [item["boundary_elapsed"] for item in transitions] == [5.0, 10.0, 15.0] + assert transitions[0]["step"].kind == "rest" + assert transitions[1]["step"].kind == "work" + print(" ok: a large sample jump preserves every exact intermediate boundary") + + +def test_first_represented_window_counts_toward_program() -> None: + spec = { + "name": "first window", + "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 1}}, + ], + } + runner = ProgramRunner(program.load_program(spec)) + state = runner.update(elapsed_s=1, distance_m=3, sample_duration_s=1) + + assert runner.done + assert [item["transition"] for item in state["transitions"]] == [ + "step_start", + "program_complete", + ] + assert state["transitions"][0]["boundary_elapsed"] == 0.0 + assert state["transitions"][1]["boundary_elapsed"] == 1.0 + + +def test_exactly_filled_window_survives_float_rounding() -> None: + """A window that exactly fills its elapsed gap must not trip the guard. + + The canonical fields are millisecond-derived, but ``2.251 - 2.0`` is + ``0.2509999999999999`` and ``32.254 - 30.254`` is ``1.9999999999999964`` in + IEEE floats, so a strict seconds comparison rejected a legitimate window + that filled its gap exactly. Both values are taken from a real high-cadence + recording that crashed the BLE callback before the guard rounded to + milliseconds. + """ + spec = {"name": "fill", "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 100000}}, + ]} + r = ProgramRunner(program.load_program(spec)) + r.update(elapsed_s=2.0, distance_m=0, sample_duration_s=1.0) + r.update(elapsed_s=2.251, distance_m=0, sample_duration_s=0.251) + r.update(elapsed_s=30.254, distance_m=0, sample_duration_s=1.0) + r.update(elapsed_s=32.254, distance_m=0, sample_duration_s=2.0) + assert not r.done and r.index == 0 + print(" ok: exactly-filled canonical windows do not raise on float rounding") + + +def test_mixed_step_overshoot_interpolates_time_and_distance() -> None: + spec = { + "name": "mixed jump", + "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 5}}, + {"type": "work", "duration": {"kind": "distance", "meters": 10}}, + {"type": "work", "duration": {"kind": "time", "seconds": 2}}, + ], + } + runner = ProgramRunner(program.load_program(spec)) + runner.update(elapsed_s=0, distance_m=0, sample_duration_s=0) + state = runner.update(elapsed_s=10, distance_m=30, sample_duration_s=10) + transitions = state["transitions"] + assert len(transitions) == 2 + assert transitions[0]["boundary_elapsed"] == 5.0 + assert transitions[0]["boundary_distance"] == 15.0 + assert transitions[1]["boundary_distance"] == 25.0 + assert abs(transitions[1]["boundary_elapsed"] - (25.0 / 3.0)) < 1e-9 + assert runner.index == 2 and not runner.done + assert abs(state["remaining"] - (1.0 / 3.0)) < 1e-9 + + +def test_remaining_reported() -> None: + spec = {"name": "r", "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 60}}, + ]} + r = ProgramRunner(program.load_program(spec)) + r.update(elapsed_s=0, distance_m=0, sample_duration_s=0) + st = r.update(elapsed_s=20, distance_m=0, sample_duration_s=0) + assert st["remaining_kind"] == "time" + assert abs(st["remaining"] - 40) < 0.01, st["remaining"] + print(f" ok: remaining time reported ({st['remaining']:.0f}s left)") + + +@contextlib.contextmanager +def _capture_program_logs(level=logging.INFO): + """Capture records emitted by app.program's module logger.""" + records = [] + + class _ListHandler(logging.Handler): + def emit(self, record): + records.append(record) + + handler = _ListHandler() + handler.setLevel(level) + program.logger.addHandler(handler) + old_level = program.logger.level + program.logger.setLevel(level) + try: + yield records + finally: + program.logger.removeHandler(handler) + program.logger.setLevel(old_level) + + +def _write(directory: Path, name: str, text: str, encoding: str = "utf-8") -> Path: + path = directory / name + path.write_text(text, encoding=encoding) + return path + + +def test_malformed_file_does_not_wipe_others() -> None: + # #1: a single malformed file must never take down the whole list. + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write(d, "good.json", + '{"name": "good", "steps": [{"type": "work", ' + '"duration": {"kind": "time", "seconds": 60}}]}') + # "steps" is an object, not an array -> old code raised AttributeError. + _write(d, "steps_obj.json", '{"name": "a", "steps": {"a": 1}}') + # A step is a bare string, not an object -> old code raised AttributeError. + _write(d, "step_str.json", '{"name": "b", "steps": ["justastring"]}') + # A step is a list. + _write(d, "step_list.json", '{"name": "c", "steps": [[1, 2, 3]]}') + with _capture_program_logs(logging.WARNING) as recs: + progs = program._load_programs_from(d) + names = [p.name for p in progs] + assert names == ["good"], f"only the good file should load, got {names}" + # Each of the three bad files logged a skip reason. + assert len(recs) >= 3, f"expected >=3 skip warnings, got {len(recs)}" + print(" ok: one malformed file no longer wipes the rest (3 skipped, 1 kept)") + + +def test_deep_repeat_is_capped_not_hung() -> None: + # #2: build a nested repeat 40 deep; naive expansion is 2^40 -> hang/OOM. + spec: dict[str, Any] = {"type": "work", "duration": {"kind": "time", "seconds": 30}} + for _ in range(40): + spec = {"repeat": 2, "steps": [spec, spec]} + full = {"name": "bomb", "steps": [spec]} + raised = False + try: + program.load_program(full) + except program.ProgramError: + raised = True + assert raised, "deeply nested repeat should raise ProgramError, not hang/OOM" + print(" ok: deeply nested repeat rejected quickly via ProgramError") + + +def test_step_cap_enforced() -> None: + # #2: a flat program over MAX_STEPS is rejected during flattening. + spec = {"name": "big", "steps": [ + {"repeat": program.MAX_STEPS + 10, "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 1}}, + ]}, + ]} + raised = False + try: + program.load_program(spec) + except program.ProgramError: + raised = True + assert raised, f"program over {program.MAX_STEPS} steps should be rejected" + print(f" ok: {program.MAX_STEPS}-step cap enforced") + + +def test_unknown_and_noncanonical_step_types_are_rejected() -> None: + for step_type in ("Restx", "REST"): + spec = {"name": "bad type", "steps": [ + {"type": step_type, "duration": {"kind": "time", "seconds": 60}}, + ]} + try: + program.load_program(spec) + except program.ProgramError: + continue + raise AssertionError(f"step type {step_type!r} should be rejected") + + +def test_bad_durations_rejected_cleanly() -> None: + # #4: negative / zero / non-numeric / list durations reject as ProgramError, + # not a raw traceback from label generation. + for bad in (-30, 0, "abc", [30], None, True): + spec = {"name": "d", "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": bad}}, + ]} + raised = False + try: + program.load_program(spec) + except program.ProgramError: + raised = True + except Exception as e: + raise AssertionError(f"duration {bad!r} raised {type(e).__name__}, " + "expected ProgramError") from e + assert raised, f"duration {bad!r} should be rejected" + print(" ok: negative/zero/non-numeric durations rejected cleanly") + + +def test_duration_kinds_and_repeat_counts_are_strict() -> None: + bad_steps = [ + {"type": "work", "duration": {"kind": "unknown"}}, + {"type": "work"}, + ] + for step in bad_steps: + try: + program.load_program({"name": "bad duration", "steps": [step]}) + except program.ProgramError: + continue + raise AssertionError(f"step {step!r} should be rejected") + + for repeat in (True, 1.5, 0, -1, "2"): + spec = { + "name": "bad repeat", + "steps": [{"repeat": repeat, "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 1}}, + ]}], + } + try: + program.load_program(spec) + except program.ProgramError: + continue + raise AssertionError(f"repeat {repeat!r} should be rejected") + + +def test_malformed_targets_are_rejected_at_load_time() -> None: + bad_targets = [ + "zone 3", + {"metric": "unknown", "low": 1, "high": 2}, + {"metric": "cadence", "low": 60, "high": 70}, + {"metric": "stroke_rate", "low": 60, "high": 70}, + {"metric": "power", "low": 300, "high": 200}, + {"metric": "hr_zone", "low": 0, "high": 6}, + ] + for target in bad_targets: + spec = { + "name": "bad target", + "steps": [ + { + "type": "work", + "duration": {"kind": "time", "seconds": 60}, + "target": target, + } + ], + } + try: + program.load_program(spec) + except program.ProgramError: + continue + raise AssertionError(f"target {target!r} should be rejected") + print(" ok: malformed targets rejected before workout execution") + + +def test_bad_duration_skips_only_that_file_with_log() -> None: + # #4: a bad-duration file is skipped with a logged reason, others survive. + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write(d, "good.json", + '{"name": "g", "steps": [{"type": "work", ' + '"duration": {"kind": "time", "seconds": 60}}]}') + _write(d, "neg.json", + '{"name": "n", "steps": [{"type": "work", ' + '"duration": {"kind": "time", "seconds": -30}}]}') + with _capture_program_logs(logging.WARNING) as recs: + warnings: list[str] = [] + progs = program._load_programs_from(d, warnings) + assert [p.name for p in progs] == ["g"] + assert any("neg.json" in r.getMessage() for r in recs) + assert any("neg.json" in w for w in warnings) + print(" ok: bad-duration file skipped with logged reason") + + +def test_bom_empty_and_invalid_json_logged() -> None: + # #5: BOM, empty-steps, missing-steps, and invalid JSON are skipped and + # logged. Only a schema-valid empty program is informational. + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write(d, "bom.json", + '{"name": "b", "steps": [{"type": "work", ' + '"duration": {"kind": "time", "seconds": 60}}]}') + _write(d, "empty.json", '{"name": "e", "steps": []}') + _write(d, "nosteps.json", '{"name": "no"}') + _write(d, "invalid.json", '{"name": "i", "steps": [}') + with _capture_program_logs(logging.INFO) as recs: + progs = program._load_programs_from(d) + assert progs == [], f"none of these should load, got {[p.name for p in progs]}" + msgs = [r.getMessage() for r in recs] + assert any("bom.json" in m for m in msgs), "BOM skip must be logged" + assert any("empty.json" in m for m in msgs), "empty-steps skip must be logged" + assert any("nosteps.json" in m for m in msgs), "missing-steps skip must be logged" + assert any("invalid.json" in m for m in msgs), "invalid JSON skip must be logged" + # Empty is valid but non-runnable; missing steps violates the schema. + empty_rec = next(r for r in recs if "empty.json" in r.getMessage()) + assert empty_rec.levelno == logging.INFO, "empty program is INFO, not a failure" + missing_rec = next(r for r in recs if "nosteps.json" in r.getMessage()) + assert missing_rec.levelno == logging.WARNING + print(" ok: malformed and non-runnable programs are logged distinctly") + + +def test_empty_program_cannot_bypass_exact_top_level_schema() -> None: + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + _write( + directory, + "extra.json", + '{"name": "empty", "steps": [], "unsupported": true}', + ) + warnings: list[str] = [] + assert program._load_programs_from(directory, warnings) == [] + assert len(warnings) == 1 + assert "exactly 'name' and 'steps'" in warnings[0] + + +def test_duplicate_names_keep_distinct_file_identifiers() -> None: + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + _write(d, "a.json", + '{"name": "Dup", "steps": [{"type": "work", ' + '"duration": {"kind": "time", "seconds": 60}}]}') + _write(d, "b.json", + '{"name": "Dup", "steps": [{"type": "rest", ' + '"duration": {"kind": "time", "seconds": 30}}]}') + progs = program._load_programs_from(d) + assert len(progs) == 2, "both duplicate-named programs still load" + assert [item.identifier for item in progs] == ["a.json", "b.json"] + assert [item.name for item in progs] == ["Dup", "Dup"] diff --git a/tests/test_program_laps.py b/tests/test_program_laps.py new file mode 100644 index 0000000..f272e25 --- /dev/null +++ b/tests/test_program_laps.py @@ -0,0 +1,864 @@ +""" +Tests for planned-step statistics and natural freestyle laps. + +Plans define the step/lap structure. The ergometer's active signal selects the +records used for each step's moving time and averages, so inactive rest remains +visible without lowering HR/cadence/power. Free workouts group exact active runs +across brief inactive gaps so low cadence does not create one-second laps. + +Run from the project root: + + python tests/test_program_laps.py +""" + +import json +import sys +import tempfile +from pathlib import Path +from typing import Any + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app.finalized_workout import finalize_workout +from app.segmentation import LiveSegmenter +from app.summary import compute_summary +from app.table import Table + +COLUMNS = [ + "timestamp", "session_elapsed", "heart_rate", "cadence", "cadence_instant", "distance", + "speed_instant", "pull_force", "pull_force_instant", "active", "sample_duration", +] +T0 = 1_700_000_000_000 # arbitrary epoch ms + + +class _WorkoutBuilder: + """Build a synthetic 1 Hz recording plus its program-step timeline.""" + + def __init__(self): + self.rows = [] + self.steps = [] + self._d = 0.0 + self._i = 0 + + def _append(self, speed, advance_m): + self._d += advance_m + self.rows.append( + { + "timestamp": T0 + (self._i + 1) * 1000, + "session_elapsed": self._i + 1, + "heart_rate": 140, + "cadence": 70 if advance_m else 0, + "cadence_instant": 70 if advance_m else 0, + "distance": self._d, + "speed_instant": speed, + "pull_force": 180 if advance_m else 0, + "pull_force_instant": 180 if advance_m else 0, + "active": 1 if advance_m else 0, + "sample_duration": 1, + } + ) + self._i += 1 + + def step(self, kind, label, phases): + """One program step made of (n_records, speed, advance_m) phases.""" + start_i = self._i + for n, speed, advance in phases: + for _ in range(n): + self._append(speed, advance) + self.steps.append( + { + "index": len(self.steps), + "type": kind, + "label": label, + "start_elapsed_s": start_i, + "end_elapsed_s": self._i, + } + ) + + def build(self): + return Table(COLUMNS, self.rows), self.steps + + @property + def timestamps(self): + return [r["timestamp"] for r in self.rows] + + @property + def distances(self): + return [r["distance"] for r in self.rows] + + +def _coast_down_workout(): + """work 60s -> rest 30s (6s of coast, then motionless) -> work 60s.""" + b = _WorkoutBuilder() + b.step("work", "Work 1:00", [(60, 3.0, 3.0)]) + b.step("rest", "Rest 0:30", [(6, 1.5, 1.5), (24, 0.0, 0.0)]) + b.step("work", "Work 1:00", [(60, 3.0, 3.0)]) + return b + + +def test_summary_uses_program_steps_and_labels_laps() -> None: + b = _coast_down_workout() + table, steps = b.build() + s = compute_summary(table, program_steps=steps) + assert len(s.laps) == 3, f"expected one lap per step, got {len(s.laps)}" + assert [lap.kind for lap in s.laps] == ["work", "rest", "work"] + assert s.laps[0].label == "Work 1:00" + assert s.laps[1].label == "Rest 0:30" + # Each planned step owns its own active-only averages. The six coast rows + # are no longer folded into Work; they belong to Rest, and its motionless + # remainder cannot lower the averages. + assert abs(s.laps[0].active_time_s - 60.0) < 1e-6, s.laps[0].active_time_s + assert s.laps[1].active_time_s == 6.0 + assert abs(s.laps[2].start_s - 90.0) < 1e-6 + print(" ok: summary reports active-only averages for every planned step") + + +def test_elapsed_step_bounds_keep_inactive_warmup_and_cooldown() -> None: + """Planned elapsed bounds retain inactive edges and ignore receipt jitter.""" + columns = COLUMNS + rows = [] + for elapsed, active, distance in ( + ((2, 0, 0.0), (4, 1, 4.0), (6, 1, 8.0), (8, 0, 8.0)) + ): + rows.append( + { + "timestamp": T0 + elapsed * 1000, + "session_elapsed": elapsed, + "heart_rate": 140, + "cadence": 70 if active else 0, + "cadence_instant": 70 if active else 0, + "distance": distance, + "pull_force": 180 if active else 0, + "pull_force_instant": 180 if active else 0, + "active": active, + "sample_duration": 2, + "speed_instant": 2.0 if active else 0.0, + } + ) + steps = [ + { + "index": 0, + "type": "warmup", + "label": "Warm-up", + "start_elapsed_s": 0, + "end_elapsed_s": 2, + }, + { + "index": 1, + "type": "work", + "label": "Work", + "start_elapsed_s": 2, + "end_elapsed_s": 6, + }, + { + "index": 2, + "type": "cooldown", + "label": "Cool-down", + "start_elapsed_s": 6, + "end_elapsed_s": 8, + }, + ] + + summary = compute_summary(Table(columns, rows), program_steps=steps) + + assert summary.elapsed_time_s == 8.0 + assert summary.active_time_s == 4.0 + assert [lap.kind for lap in summary.laps] == ["warmup", "work", "cooldown"] + assert [lap.start_s for lap in summary.laps] == [0.0, 2.0, 6.0] + assert [lap.elapsed_time_s for lap in summary.laps] == [2.0, 4.0, 2.0] + assert [lap.active_time_s for lap in summary.laps] == [0.0, 4.0, 0.0] + assert [lap.distance_m for lap in summary.laps] == [0.0, 8.0, 0.0] + assert summary.series_t == [4.0, 6.0] + assert summary.series_time_bounds == (0.0, 8.0) + + +def test_steps_sidecar_persists_device_elapsed_boundaries() -> None: + from app.program import load_program + from app.workout_session import WorkoutSession + + workout = load_program( + { + "name": "elapsed sidecar", + "steps": [ + {"type": "work", "duration": {"kind": "time", "seconds": 5}}, + {"type": "rest", "duration": {"kind": "time", "seconds": 5}}, + ], + } + ) + with tempfile.TemporaryDirectory() as directory: + csv_path = Path(directory) / "workout.csv" + session = WorkoutSession({"log_level": "warning"}, program=workout) + session.csv_path = str(csv_path) + session._record_step_boundary( + { + "transition": "step_start", + "step_index": 0, + "step": workout.steps[0], + "boundary_elapsed": 0.0, + } + ) + session._record_step_boundary( + { + "transition": "step_start", + "step_index": 1, + "step": workout.steps[1], + "boundary_elapsed": 5.0, + } + ) + session._last_session_elapsed_s = 8.0 + session._write_program_steps(close_open=False) + + payload = json.loads((Path(directory) / "steps.json").read_text()) + + assert payload["steps"][0]["start_elapsed_s"] == 0.0 + assert payload["steps"][0]["end_elapsed_s"] == 5.0 + assert payload["steps"][1]["start_elapsed_s"] == 5.0 + assert payload["steps"][1]["end_elapsed_s"] == 8.0 + + +def test_summary_agrees_with_fit_export() -> None: + """The in-app summary and the exported FIT must show the same laps.""" + from app.export_fit import FitExporter + + b = _coast_down_workout() + table, steps = b.build() + + class _RecordingBuilder: + def __init__(self): + self.messages = [] + + def add(self, message=None): + self.messages.append(message) + + exporter = FitExporter() + finalized = finalize_workout(table, program_steps=steps) + exporter._add_records( + builder=_RecordingBuilder(), + workout_df=table, + finalized=finalized, + ) + + summary = compute_summary(table, program_steps=steps) + assert len(finalized.laps) == len(summary.laps), ( + f"FIT has {len(finalized.laps)} laps, summary has {len(summary.laps)}" + ) + t0 = finalized.start_time_ms + assert t0 is not None + for seg, lap in zip(finalized.laps, summary.laps, strict=False): + fit_start_s = (seg.start_time_ms - t0) / 1000.0 + fit_elapsed_s = (seg.end_time_ms - seg.start_time_ms) / 1000.0 + fit_duration_s = seg.timer_time_ms / 1000.0 + assert abs(fit_start_s - lap.start_s) < 1e-6, (fit_start_s, lap.start_s) + assert abs(fit_elapsed_s - lap.elapsed_time_s) < 1e-6, ( + fit_elapsed_s, + lap.elapsed_time_s, + ) + assert abs(fit_duration_s - lap.active_time_s) < 1e-6, ( + fit_duration_s, + lap.active_time_s, + ) + print(f" ok: FIT export and summary agree on {len(summary.laps)} identical laps") + + +def test_program_lap_is_clipped_to_active_signal() -> None: + """One planned step groups active bursts but excludes inactive samples.""" + columns = COLUMNS + actives = [0, 1, 1, 0, 1, 1, 0] + distances = [0.0, 0.0, 3.0, 3.0, 6.0, 9.0, 9.0] + rows = [] + for i, active in enumerate(actives): + rows.append( + { + "timestamp": T0 + (i + 1) * 1000, + "session_elapsed": i + 1, + "heart_rate": 120 + i, + "cadence": 70 if active else 0, + "cadence_instant": 70 if active else 0, + "distance": distances[i], + "speed_instant": 3.0 if active else 0.0, + "pull_force": 180 if active else 0, + "pull_force_instant": 180 if active else 0, + "active": active, + "sample_duration": 1, + } + ) + steps = [ + { + "index": 0, + "type": "work", + "label": "Work", + "start_elapsed_s": 0, + "end_elapsed_s": 7, + } + ] + summary = compute_summary(Table(columns, rows), program_steps=steps) + assert summary.active_time_s == 4.0 + assert len(summary.laps) == 1 + assert summary.series_hr == [121.0, 122.0, 124.0, 125.0] + # The two active bursts are separated by a single inactive second, well + # under the five-second meaningful pause, so the chart line stays continuous + # (one break) even though each burst is its own exact timer run (D019). + assert summary.series_breaks == [True, False, False, False] + assert summary.laps[0].avg_hr == 123.0 + assert summary.laps[0].avg_spm == 70.0 + print(" ok: planned lap groups active bursts without counting inactive time") + + +def test_sample_window_crossing_step_boundary_is_split_exactly() -> None: + """A sensor window can contribute proportionally to two adjacent steps.""" + columns = COLUMNS + table = Table( + columns, + [ + { + "timestamp": T0 + 2000, + "session_elapsed": 2, + "heart_rate": 150, + "cadence": 80, + "cadence_instant": 80, + "distance": 10.0, + "speed_instant": 5.0, + "pull_force": 200, + "pull_force_instant": 200, + "active": 1, + "sample_duration": 2.0, + } + ], + ) + steps = [ + {"index": 0, "type": "work", "label": "A", "start_elapsed_s": 0, "end_elapsed_s": 1}, + { + "index": 1, + "type": "rest", + "label": "B", + "start_elapsed_s": 1, + "end_elapsed_s": 2, + }, + ] + + finalized = finalize_workout(table, program_steps=steps) + summary = compute_summary(table, program_steps=steps) + + assert [lap.record_weights_s for lap in finalized.laps] == [(1.0,), (1.0,)] + assert [lap.distance_m for lap in summary.laps] == [5.0, 5.0] + assert [lap.active_time_s for lap in summary.laps] == [1.0, 1.0] + assert sum(lap.distance_m for lap in summary.laps) == summary.distance_m + assert sum(lap.active_time_s for lap in summary.laps) == summary.active_time_s + + +def test_centimetre_split_keeps_every_average_bounded_by_its_maximum() -> None: + """FIT distance quantization may not produce an avg/max inversion.""" + table = Table( + COLUMNS, + [ + { + "timestamp": T0 + 1000, + "session_elapsed": 1, + "heart_rate": 150, + "cadence": 80, + "cadence_instant": 80, + "distance": 0.01, + "speed_instant": 0.01, + "pull_force": 200, + "pull_force_instant": 200, + "active": 1, + "sample_duration": 1.0, + } + ], + ) + steps = [ + {"index": 0, "type": "work", "label": "A", "start_elapsed_s": 0, "end_elapsed_s": 0.5}, + { + "index": 1, + "type": "rest", + "label": "B", + "start_elapsed_s": 0.5, + "end_elapsed_s": 1, + }, + ] + + summary = compute_summary(table, program_steps=steps) + assert sum(lap.distance_m for lap in summary.laps) == summary.distance_m == 0.01 + assert all( + lap.avg_speed_mps is None + or ( + lap.max_speed_mps is not None + and lap.avg_speed_mps <= lap.max_speed_mps + ) + for lap in summary.laps + ) + assert summary.avg_speed_mps is not None + assert summary.max_speed_mps is not None + assert summary.avg_speed_mps <= summary.max_speed_mps + + +def test_session_speed_is_weighted_lap_speed_and_raw_peak() -> None: + columns = COLUMNS + rows = [ + { + "timestamp": T0 + 1000, + "session_elapsed": 1, + "heart_rate": 140, + "cadence": 70, + "cadence_instant": 70, + "distance": 4.0, + "speed_instant": 3.0, + "pull_force": 180, + "pull_force_instant": 180, + "active": 1, + "sample_duration": 1, + }, + { + "timestamp": T0 + 4000, + "session_elapsed": 4, + "heart_rate": 140, + "cadence": 70, + "cadence_instant": 70, + "distance": 7.0, + "speed_instant": 3.0, + "pull_force": 180, + "pull_force_instant": 180, + "active": 1, + "sample_duration": 3, + }, + ] + steps = [ + {"index": 0, "type": "work", "label": "Fast", "start_elapsed_s": 0, "end_elapsed_s": 1}, + { + "index": 1, + "type": "work", + "label": "Easy", + "start_elapsed_s": 1, + "end_elapsed_s": 4, + }, + ] + + summary = compute_summary(Table(columns, rows), program_steps=steps) + + # Both laps and the session use the time-weighted instantaneous speed + # channel, independently from cumulative odometer allocation. + assert [lap.avg_speed_mps for lap in summary.laps] == [3.0, 3.0] + assert summary.avg_speed_mps == 3.0 + assert summary.avg_speed_mps == sum( + (lap.avg_speed_mps or 0.0) * lap.active_time_s for lap in summary.laps + ) / summary.active_time_s + assert summary.max_speed_mps == 3.0 + # The hierarchy can never contradict itself: every peak is at least its own + # average and is drawn from the same recorded sensor channel. + lap_averages = [ + value + for lap in summary.laps + if (value := lap.avg_speed_mps) is not None + ] + lap_maxima = [ + value + for lap in summary.laps + if (value := lap.max_speed_mps) is not None + ] + assert len(lap_averages) == len(lap_maxima) == len(summary.laps) + assert all( + maximum >= average + for average, maximum in zip(lap_averages, lap_maxima, strict=True) + ) + assert summary.max_speed_mps == max(lap_maxima) + + +def test_terminal_planned_step_uses_the_persisted_session_endpoint() -> None: + """Capture persists the final performed endpoint, including sensor granularity.""" + rows = [ + { + "timestamp": T0 + i * 1000, + "session_elapsed": i, + "heart_rate": 140, + "cadence": 70, + "cadence_instant": 70, + "distance": float(i * 3), + "speed_instant": 3.0, + "pull_force": 180, + "pull_force_instant": 180, + "active": 1, + "sample_duration": 1, + } + for i in range(1, 6) + ] + steps = [ + { + "type": "work", + "label": "10 m", + "index": 0, + "start_elapsed_s": 0, + "end_elapsed_s": 5, + } + ] + + table = Table(COLUMNS, rows) + finalized = finalize_workout(table, program_steps=steps) + summary = compute_summary(table, program_steps=steps) + + assert finalized.laps[0].end_time_ms == T0 + 5_000 + assert finalized.laps[0].timer_time_ms == finalized.total_timer_time_ms == 5_000 + assert finalized.laps[0].distance_m == finalized.total_distance_m == 15.0 + assert summary.laps[0].active_time_s == summary.active_time_s == 5.0 + assert summary.laps[0].distance_m == summary.distance_m == 15.0 + + +@pytest.mark.parametrize("resume_speed", [0.0, None, "invalid", float("nan")]) +def test_resume_odometer_jump_is_credited_independently_of_speed( + resume_speed: Any, +) -> None: + """Speed validity never rewrites cumulative distance.""" + distances = [3.0, 6.0, 6.0, 6.0, 18.0, 21.0] + speeds: list[Any] = [3.0, 3.0, 0.0, 0.0, resume_speed, 3.0] + actives = [1, 1, 0, 0, 1, 1] + rows = [ + { + "timestamp": T0 + (i + 1) * 1000, + "session_elapsed": i + 1, + "heart_rate": 140, + "cadence": 70 if active else 0, + "cadence_instant": 70 if active else 0, + "distance": distances[i], + "speed_instant": speeds[i], + "pull_force": 180 if active else 0, + "pull_force_instant": 180 if active else 0, + "active": active, + "sample_duration": 1, + } + for i, active in enumerate(actives) + ] + + finalized = finalize_workout(Table(COLUMNS, rows)) + + assert finalized.total_distance_m == 21.0 + assert finalized.record_cumulative_distance_m[4] == 18.0 + assert finalized.record_cumulative_distance_m[5] == 21.0 + + +def test_meaningful_pause_breaks_chart_without_splitting_planned_lap() -> None: + """A long pause remains visible inside its single planned step/lap.""" + columns = COLUMNS + actives = [1, 1, 0, 0, 0, 0, 0, 1, 1] + rows = [] + distance = 0.0 + for i, active in enumerate(actives): + if active: + distance += 3.0 + rows.append( + { + "timestamp": T0 + (i + 1) * 1000, + "session_elapsed": i + 1, + "heart_rate": 130 + i, + "cadence": 70 if active else 0, + "cadence_instant": 70 if active else 0, + "distance": distance, + "speed_instant": 3.0 if active else 0.0, + "pull_force": 180 if active else 0, + "pull_force_instant": 180 if active else 0, + "active": active, + "sample_duration": 1, + } + ) + steps = [ + { + "index": 0, + "type": "work", + "label": "Long work", + "start_elapsed_s": 0, + "end_elapsed_s": 9, + } + ] + summary = compute_summary(Table(columns, rows), program_steps=steps) + assert summary.active_time_s == 4.0 + assert len(summary.laps) == 1 + assert summary.series_breaks == [True, False, True, False] + print(" ok: long pause breaks chart but planned step remains one lap") + + +def test_planned_chart_line_bridges_subthreshold_uncovered_gap() -> None: + """A high-cadence uncovered second keeps the planned chart line continuous. + + At high cadence the ergometer can advance elapsed time by two seconds while + reporting a one-second window, leaving one uncovered second inside otherwise + unbroken paddling. That uncovered interval still closes an exact timer run, + but the chart line must not fragment for a gap far below the five-second + meaningful pause (D019). Here elapsed jumps 2 -> 4 s with a one-second + window, so ``[2, 3]`` is uncovered between two active runs. + """ + columns = COLUMNS + elapseds = [1, 2, 4, 5] + rows = [] + distance = 0.0 + for elapsed in elapseds: + distance += 3.0 + rows.append( + { + "timestamp": T0 + elapsed * 1000, + "session_elapsed": elapsed, + "heart_rate": 150, + "cadence": 120, + "cadence_instant": 120, + "distance": distance, + "speed_instant": 4.0, + "pull_force": 190, + "pull_force_instant": 190, + "active": 1, + "sample_duration": 1, + } + ) + steps = [ + { + "index": 0, + "type": "work", + "label": "Sprint", + "start_elapsed_s": 0, + "end_elapsed_s": 5, + } + ] + table = Table(columns, rows) + # The uncovered second still splits the exact timer domain into two runs ... + finalized = finalize_workout(table, program_steps=steps) + assert len(finalized.segments) == 2 + # ... but presentation keeps one lap and one unbroken chart line. + summary = compute_summary(table, program_steps=steps) + assert len(summary.laps) == 1 + assert summary.active_time_s == 4.0 + assert summary.series_breaks == [True, False, False, False] + print(" ok: sub-threshold uncovered gap keeps the planned chart line whole") + + +def test_active_rest_stays_a_labelled_lap() -> None: + """An athlete who keeps paddling hard through a 'rest' step really did move: + that effort stays a distinct lap (honest data), but carries kind='rest' so + the UI can label it instead of showing an anonymous interval.""" + b = _WorkoutBuilder() + b.step("work", "Work 1:00", [(60, 3.0, 3.0)]) + b.step("rest", "Rest 0:30", [(30, 2.0, 2.0)]) # 58 m of real paddling + b.step("work", "Work 1:00", [(60, 3.0, 3.0)]) + table, steps = b.build() + + s = compute_summary(table, program_steps=steps) + assert len(s.laps) == 3 + assert s.laps[1].kind == "rest" and s.laps[1].label == "Rest 0:30" + assert len(s.series_steps) == 3 + # Adjacent planned phases are shown as chart bands, not chopped into three + # polylines. Only the first sample (or a real pause) starts a line. + assert sum(s.series_breaks) == 1 + print(" ok: hard-paddled rest step kept as its own lap, labelled 'rest'") + + +def test_summary_keeps_motionless_rest_as_empty_planned_step() -> None: + """The plan view keeps Rest, but inactive samples cannot lower averages.""" + columns = COLUMNS + rows = [] + distance = 0.0 + actives = [1] * 10 + [0] * 5 + [1] * 10 + for i, active in enumerate(actives): + if active: + distance += 3.0 + rows.append( + { + "timestamp": T0 + (i + 1) * 1000, + "session_elapsed": i + 1, + "heart_rate": 140 if active else 100, + "cadence": 70 if active else 0, + "cadence_instant": 70 if active else 0, + "distance": distance, + "speed_instant": 3.0 if active else 0.0, + "pull_force": 180 if active else 0, + "pull_force_instant": 180 if active else 0, + "active": active, + "sample_duration": 1, + } + ) + steps = [ + { + "index": 0, + "type": "work", + "label": "Work 1", + "start_elapsed_s": 0, + "end_elapsed_s": 10, + }, + { + "index": 1, + "type": "rest", + "label": "Rest", + "start_elapsed_s": 10, + "end_elapsed_s": 15, + }, + { + "index": 2, + "type": "work", + "label": "Work 2", + "start_elapsed_s": 15, + "end_elapsed_s": 25, + }, + ] + summary = compute_summary(Table(columns, rows), program_steps=steps) + assert [lap.kind for lap in summary.laps] == ["work", "rest", "work"] + rest = summary.laps[1] + assert rest.active_time_s == 0.0 + assert rest.elapsed_time_s == 5.0 + assert rest.avg_hr is None and rest.avg_power is None and rest.avg_spm is None + from app.export_fit import FitExporter + + with tempfile.TemporaryDirectory() as directory: + fit_path = Path(directory) / "planned-rest.fit" + assert FitExporter().export( + Table(columns, rows), str(fit_path), program_steps=steps + ) + assert fit_path.exists() + print(" ok: pure rest remains visible with blank active-only averages") + + +def test_planned_rest_survives_a_recording_gap() -> None: + """The durable plan, not presence of idle CSV packets, defines the steps.""" + columns = COLUMNS + rows = [ + { + "timestamp": T0 + elapsed * 1000, + "session_elapsed": elapsed, + "heart_rate": 140, + "cadence": 70, + "cadence_instant": 70, + "distance": float(i * 3), + "speed_instant": 3.0, + "pull_force": 180, + "pull_force_instant": 180, + "active": 1, + "sample_duration": 1, + } + for i, elapsed in enumerate((1, 2, 8, 9), start=1) + ] + steps = [ + {"index": 0, "type": "work", "label": "Work 1", "start_elapsed_s": 0, "end_elapsed_s": 2}, + {"index": 1, "type": "rest", "label": "Rest", "start_elapsed_s": 2, "end_elapsed_s": 7}, + {"index": 2, "type": "work", "label": "Work 2", "start_elapsed_s": 7, "end_elapsed_s": 9}, + ] + summary = compute_summary(Table(columns, rows), program_steps=steps) + assert [lap.kind for lap in summary.laps] == ["work", "rest", "work"] + rest = summary.laps[1] + assert rest.active_time_s == 0.0 and rest.elapsed_time_s == 5.0 + assert rest.avg_hr is None + print(" ok: planned rest remains visible even when no idle row was recorded") + + +def test_all_malformed_steps_are_rejected() -> None: + b = _coast_down_workout() + # Deliberately malformed timeline (wrong item types) to exercise the + # skip-and-fall-back path; typed list[Any] so mypy accepts the bad input. + bad_steps: list[Any] = [ + "nope", + 123, + None, + {"start_elapsed_s": "x", "end_elapsed_s": "y"}, + ] + table, _steps = b.build() + with pytest.raises(ValueError, match="invalid step"): + compute_summary(table, program_steps=bad_steps) + + +def test_summary_rejects_partially_malformed_steps() -> None: + b = _coast_down_workout() + table, steps = b.build() + bad_steps = list(steps) + bad_steps.insert(1, "oops-not-a-dict") # Case A + bad_steps.append({"start_elapsed_s": "x", "end_elapsed_s": 5}) # Case B + + with pytest.raises(ValueError, match="invalid step"): + compute_summary(table, program_steps=bad_steps) + + +def test_finalizer_rejects_partially_malformed_steps() -> None: + b = _coast_down_workout() + table, steps = b.build() + bad_steps = list(steps) + bad_steps.insert(1, "oops-not-a-dict") + + with pytest.raises(ValueError, match="invalid step"): + finalize_workout(table, program_steps=bad_steps) + + +def test_free_workout_uses_activity_based_laps() -> None: + b = _coast_down_workout() + table, _steps = b.build() + + s = compute_summary(table) # no program_steps at all + assert all(lap.kind is None and lap.label is None for lap in s.laps) + + assert len(s.laps) == 2 + with pytest.raises(ValueError, match="non-empty"): + compute_summary(table, program_steps=[]) + + +def test_free_workout_groups_short_inactivity_without_lowering_averages() -> None: + actives = [1, 1, 0, 0, 0, 0, 1, 1] + rows = [] + distance = 0.0 + for i, active in enumerate(actives): + if active: + distance += 3.0 + rows.append( + { + "timestamp": T0 + (i + 1) * 1000, + "session_elapsed": i + 1, + "heart_rate": 140 if active else 60, + "cadence": 70 if active else 0, + "cadence_instant": 70 if active else 0, + "distance": distance, + "speed_instant": 3.0 if active else 0.0, + "pull_force": 180 if active else 0, + "pull_force_instant": 180 if active else 0, + "active": active, + "sample_duration": 1, + } + ) + + summary = compute_summary(Table(COLUMNS, rows)) + + assert len(summary.laps) == 1 + assert summary.active_time_s == summary.laps[0].active_time_s == 4.0 + assert summary.laps[0].elapsed_time_s == 8.0 + assert summary.laps[0].avg_spm == 70.0 + assert summary.laps[0].avg_hr == 140.0 + assert summary.series_breaks == [True, False, False, False] + live = LiveSegmenter() + for active in actives: + live_state = live.update(active=active) + assert live_state["lap"] == len(summary.laps) == 1 + + +def test_free_workout_splits_at_exact_five_second_pause() -> None: + actives = [1, 1, 0, 0, 0, 0, 0, 1, 1] + rows = [] + distance = 0.0 + for i, active in enumerate(actives): + if active: + distance += 3.0 + rows.append( + { + "timestamp": T0 + (i + 1) * 1000, + "session_elapsed": i + 1, + "heart_rate": 140, + "cadence": 70 if active else 0, + "cadence_instant": 70 if active else 0, + "distance": distance, + "speed_instant": 3.0 if active else 0.0, + "pull_force": 180 if active else 0, + "pull_force_instant": 180 if active else 0, + "active": active, + "sample_duration": 1, + } + ) + + summary = compute_summary(Table(COLUMNS, rows)) + + assert len(summary.laps) == 2 + assert [lap.active_time_s for lap in summary.laps] == [2.0, 2.0] + assert summary.active_time_s == 4.0 + assert summary.series_breaks == [True, False, True, False] + live = LiveSegmenter() + for active in actives: + live_state = live.update(active=active) + assert live_state["lap"] == len(summary.laps) == 2 diff --git a/tests/test_read_csv.py b/tests/test_read_csv.py new file mode 100644 index 0000000..78b72ef --- /dev/null +++ b/tests/test_read_csv.py @@ -0,0 +1,283 @@ +""" +Tests for the stdlib CSV reader (replacement for the former Polars reader). + +Covers typed parsing, preservation of the raw timeline, schema validation, and +the resulting Table shape. + +Run from the project root: + + python tests/test_read_csv.py +""" + +import contextlib +import logging +import sys +import tempfile +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app.field_mapping import CSV_COLUMNS +from app.read_csv import CsvReader, CsvReadError + +CONFIG = {"log_level": "warning"} + + +@contextlib.contextmanager +def _capture_reader_logs(level=logging.WARNING): + """Capture records emitted by app.read_csv's module logger.""" + records = [] + + class _ListHandler(logging.Handler): + def emit(self, record): + records.append(record) + + logger = logging.getLogger("app.read_csv") + handler = _ListHandler() + handler.setLevel(level) + logger.addHandler(handler) + old_level = logger.level + logger.setLevel(level) + try: + yield records + finally: + logger.removeHandler(handler) + logger.setLevel(old_level) + + +def _row(i: int, speed: str = "3.20", distance: str | None = None) -> dict[str, Any]: + dist = distance if distance is not None else f"{i * 3.2:.2f}" + return { + "timestamp": 1_700_000_000_000 + i * 1000, + "session_elapsed__s": i, + "sample_duration__s": 1, + "heart_rate__bpm": 120 + (i % 10), + "kayakfirst_timestamp": 1_700_000_000_000 + i * 1000, + "col_2": 0, "col_3": 0, "col_4": "0.00", "col_5": "0.00", + "col_6": 0, "active_paddling": 1 if float(speed) > 0 else 0, "col_8": 0, + "distance__m": dist, + "speed__mps": speed, "speed_instant__mps": speed, + "cadence__spm": 80, "cadence_instant__spm": 80, + "pace_200m__s": 62, "pace_200m_instant__s": 62, + "pace_500m__s": 156, "pace_500m_instant__s": 156, + "pace_1000m__s": 312, "pace_1000m_instant__s": 312, + "pull_force__n": 200, "pull_force_instant__n": 200, + "elapsed_time__s": i, "window_size__s": 1, + } + + +def _write(path: Path, rows: list[Any], full_schema: bool = True) -> None: + fields = CSV_COLUMNS if full_schema else CSV_COLUMNS[:5] + _write_cols(path, rows, fields) + + +def _write_cols(path: Path, rows: list[Any], fields: list[str]) -> None: + """Write a CSV with an explicit column list (for schema-tolerance tests).""" + lines = [";".join(fields)] + for r in rows: + lines.append(";".join(str(r[f]) for f in fields)) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def test_parse_types_and_shape() -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + _write(p, [_row(i) for i in range(1, 6)]) + t = CsvReader(str(p)).read_all() + assert t.columns == [ + "timestamp", "session_elapsed", "sample_duration", + "device_elapsed", "device_window", "kayakfirst_timestamp", + "heart_rate", "cadence_instant", "distance", "speed_instant", + "pull_force_instant", "active", + ] + assert t.height == 5 + assert isinstance(t.get_column("timestamp")[0], int) + assert isinstance(t.get_column("distance")[0], Decimal) + assert isinstance(t.get_column("speed_instant")[0], Decimal) + print(" ok: typed columns (int timestamp, Decimal distance/speed)") + + +def test_unused_malformed_pace_does_not_discard_sensor_row() -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + row = _row(1) + row["pace_200m__s"] = "(0, 0)" + _write(p, [row]) + + table = CsvReader(str(p)).read_all() + + assert table.height == 1 + assert table.get_column("distance") == [Decimal("3.20")] + + +def test_canonical_metric_channels_are_fixed() -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + row = _row(1) + row["speed__mps"] = "2.50" + row["speed_instant__mps"] = "3.25" + row["cadence__spm"] = 60 + row["cadence_instant__spm"] = 90 + row["pull_force__n"] = 100 + row["pull_force_instant__n"] = 200 + _write(p, [row]) + + table = CsvReader(str(p)).read_all() + + assert table.get_column("speed_instant") == [Decimal("3.25")] + assert table.get_column("cadence_instant") == [90] + assert table.get_column("pull_force_instant") == [200] + assert "cadence" not in table + assert "pull_force" not in table + + +def test_stationary_rows_are_preserved() -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + # Stationary rows are part of the canonical timeline; segmentation, + # rather than the CSV parser, decides whether they reach the FIT file. + rows = [_row(i) for i in range(1, 4)] + rows += [_row(i, speed="0.00", distance=f"{3 * 3.2:.2f}") for i in range(4, 6)] + _write(p, rows) + t = CsvReader(str(p)).read_all() + assert t.height == 5, f"expected all 5 rows, got {t.height}" + print(" ok: stationary rows preserved for pause detection") + + +def test_repeated_payload_rows_are_preserved() -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + # Equal metric payloads at distinct timestamps are not provably duplicate + # sensor events and must remain in the canonical timeline. + r1 = _row(1, distance="10.00") + dup = dict(r1) + dup["timestamp"] = r1["timestamp"] + 1000 + dup["session_elapsed__s"] = 2 + dup["heart_rate__bpm"] = r1["heart_rate__bpm"] + 5 + r3 = _row(1, distance="20.00") # different distance -> distinct row + r3["timestamp"] = r1["timestamp"] + 2000 + r3["session_elapsed__s"] = 3 + _write(p, [r1, dup, r3]) + t = CsvReader(str(p)).read_all() + assert t.height == 3, f"expected all repeated samples, got {t.height}" + assert t.get_column("timestamp")[0] == r1["timestamp"] + print(" ok: repeated payload rows preserved") + + +def test_schema_mismatch_raises() -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "bad.csv" + _write(p, [_row(i) for i in range(1, 4)], full_schema=False) + try: + CsvReader(str(p)).read_all() + raised = False + except CsvReadError: + raised = True + assert raised, "expected CsvReadError on schema mismatch" + print(" ok: schema mismatch raises CsvReadError") + + +def test_one_corrupt_cell_keeps_other_rows() -> None: + # Primary fix: a single unparseable cell skips only its row, never the file. + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + rows = [_row(i) for i in range(1, 21)] # 20 distinct moving rows + rows[9]["distance__m"] = "abc" # 10th data row -> line 11, decimal col + _write(p, rows) + with _capture_reader_logs() as recs: + t = CsvReader(str(p)).read_all() + assert t.height == 19, f"expected 19 surviving rows, got {t.height}" + msgs = [r.getMessage() for r in recs] + assert any("line 11" in m and "distance__m" in m for m in msgs), \ + f"expected a per-row skip warning naming line 11/distance__m, got {msgs}" + print(" ok: one corrupt cell skips only its row, rest survive (warned)") + + +def test_mostly_corrupt_file_raises() -> None: + # Majority-corrupt file is surfaced distinctly, not returned as a fragment. + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "bad.csv" + rows = [_row(i) for i in range(1, 11)] # 10 data rows + for j in range(6): # 6/10 = 60% > 50% threshold + rows[j]["distance__m"] = "xx" + _write(p, rows) + try: + CsvReader(str(p)).read_all() + raised, msg = False, "" + except CsvReadError as e: + raised, msg = True, str(e) + assert raised, "a majority-corrupt file should raise CsvReadError" + assert "mostly unreadable" in msg, f"expected distinct message, got {msg!r}" + print(" ok: majority-corrupt file raises a distinct CsvReadError") + + +def test_missing_column_is_rejected() -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + fields = [f for f in CSV_COLUMNS if f != "active_paddling"] + _write_cols(p, [_row(i) for i in range(1, 6)], fields) + with pytest.raises(CsvReadError, match="active_paddling"): + CsvReader(str(p)).read_all() + + +def test_unknown_extra_column_is_rejected() -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + rows = [_row(i) for i in range(1, 6)] + for r in rows: + r["future_metric"] = 42 + _write_cols(p, rows, [*CSV_COLUMNS, "future_metric"]) + with pytest.raises(CsvReadError, match="future_metric"): + CsvReader(str(p)).read_all() + + +def test_missing_required_column_raises() -> None: + # Secondary fix: a load-bearing column missing still fails clearly. + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "bad.csv" + fields = [f for f in CSV_COLUMNS if f != "distance__m"] + _write_cols(p, [_row(i) for i in range(1, 6)], fields) + try: + CsvReader(str(p)).read_all() + raised, msg = False, "" + except CsvReadError as e: + raised, msg = True, str(e) + assert raised, "missing a required column must raise CsvReadError" + assert "distance__m" in msg, f"error should name the missing column, got {msg!r}" + print(" ok: missing load-bearing column still raises CsvReadError") + + +def test_length_mismatch_row_still_skipped() -> None: + # Regression: a field-count-mismatched (crash-truncated) row is skipped, the + # rest survive — unchanged pre-existing behavior. + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "w.csv" + rows = [_row(i) for i in range(1, 6)] + _write(p, rows) + lines = p.read_text(encoding="utf-8").splitlines() + lines[3] = ";".join(lines[3].split(";")[:-4]) # truncate 3rd data row + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + with _capture_reader_logs() as recs: + t = CsvReader(str(p)).read_all() + assert t.height == 4, f"expected 4 rows after skipping truncated one, got {t.height}" + assert any("field-count mismatch" in r.getMessage() for r in recs), \ + "truncated row skip should be logged" + print(" ok: field-count-mismatch row still skipped, rest survive") + + +def test_empty_file_raises() -> None: + # Regression: a genuinely empty file still fails clearly. + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "empty.csv" + p.write_text("", encoding="utf-8") + try: + CsvReader(str(p)).read_all() + raised = False + except CsvReadError: + raised = True + assert raised, "empty file should raise CsvReadError" + print(" ok: empty file still raises CsvReadError") diff --git a/tests/test_recording_timeline.py b/tests/test_recording_timeline.py new file mode 100644 index 0000000..fbed665 --- /dev/null +++ b/tests/test_recording_timeline.py @@ -0,0 +1,152 @@ +"""Canonical capture-time recording timeline contracts.""" + +from app.finalized_workout import finalize_workout +from app.recording_timeline import RecordingTimeline +from app.speed_series import LiveSpeedTracker +from app.stats import aggregate_metrics +from app.table import Table + + +def _timeline() -> RecordingTimeline: + return RecordingTimeline(epoch_start_ms=1_700_000_000_000, monotonic_start_s=10.0) + + +def test_first_zero_elapsed_packet_is_a_zero_duration_observation() -> None: + timing = _timeline().advance( + device_elapsed_s=0, device_window_s=1, receipt_monotonic_s=10.2 + ) + + assert timing.session_elapsed_s == 0.0 + assert timing.sample_duration_s == 0.0 + assert timing.gap_before_s == 0.0 + + +def test_normal_and_multi_second_windows_follow_device_elapsed() -> None: + timeline = _timeline() + first = timeline.advance( + device_elapsed_s=1, device_window_s=1, receipt_monotonic_s=11 + ) + delayed = timeline.advance( + device_elapsed_s=3, device_window_s=2, receipt_monotonic_s=13.4 + ) + + assert first.session_elapsed_s == first.sample_duration_s == 1.0 + assert delayed.session_elapsed_s == 3.0 + assert delayed.sample_duration_s == 2.0 + assert delayed.gap_before_s == 0.0 + + +def test_lagging_multi_second_window_is_clipped_after_an_uncovered_gap() -> None: + timeline = _timeline() + first = timeline.advance( + device_elapsed_s=1, device_window_s=1, receipt_monotonic_s=11 + ) + jumped = timeline.advance( + device_elapsed_s=3, device_window_s=1, receipt_monotonic_s=13 + ) + lagging_window = timeline.advance( + device_elapsed_s=4, device_window_s=2, receipt_monotonic_s=14 + ) + + assert first.sample_duration_s == 1.0 + assert jumped.sample_duration_s == 1.0 + assert jumped.gap_before_s == 1.0 + assert lagging_window.sample_duration_s == 1.0 + assert lagging_window.gap_before_s == 0.0 + + rows = [ + { + "timestamp": timing.timestamp_ms, + "session_elapsed": timing.session_elapsed_s, + "sample_duration": timing.sample_duration_s, + "distance": distance, + "speed_instant": 2.0, + "active": 1, + } + for timing, distance in zip( + (first, jumped, lagging_window), (2.0, 4.0, 6.0), strict=True + ) + ] + finalized = finalize_workout(Table(list(rows[0]), rows)) + + assert finalized.total_elapsed_time_ms == 4_000 + assert finalized.total_timer_time_ms == 3_000 + assert len(finalized.segments) == 2 + metrics = aggregate_metrics( + speed=[1.0, 3.0, 5.0], + sample_weights=[ + first.sample_duration_s, + jumped.sample_duration_s, + lagging_window.sample_duration_s, + ], + ) + assert metrics["avg_speed"] == 3.0 + + +def test_repeated_counter_at_same_receipt_does_not_invent_active_time() -> None: + timeline = _timeline() + timeline.advance(device_elapsed_s=1, device_window_s=1, receipt_monotonic_s=11) + repeated = timeline.advance( + device_elapsed_s=1, device_window_s=1, receipt_monotonic_s=11 + ) + + assert repeated.session_elapsed_s == 1.0 + assert repeated.sample_duration_s == 0.0 + + +def test_counter_reset_rebases_to_receipt_time_and_exposes_gap() -> None: + timeline = _timeline() + timeline.advance(device_elapsed_s=5, device_window_s=1, receipt_monotonic_s=15) + reset = timeline.advance( + device_elapsed_s=0, device_window_s=1, receipt_monotonic_s=40 + ) + resumed = timeline.advance( + device_elapsed_s=1, device_window_s=1, receipt_monotonic_s=41 + ) + + assert reset.session_elapsed_s == 30.0 + assert reset.sample_duration_s == 1.0 + assert reset.gap_before_s == 24.0 + assert resumed.session_elapsed_s == 31.0 + assert resumed.sample_duration_s == 1.0 + assert resumed.gap_before_s == 0.0 + + +def test_reconnect_gap_keeps_live_and_finalized_totals_equal() -> None: + timeline = _timeline() + tracker = LiveSpeedTracker() + raw_samples = [ + (1, 11.0, 2.0), + (2, 12.0, 4.0), + (0, 40.0, 6.0), + ] + rows = [] + for raw_elapsed, receipt, distance in raw_samples: + timing = timeline.advance( + device_elapsed_s=raw_elapsed, + device_window_s=1, + receipt_monotonic_s=receipt, + ) + row = { + "timestamp": timing.timestamp_ms, + "session_elapsed": timing.session_elapsed_s, + "sample_duration": timing.sample_duration_s, + "distance": distance, + "speed_instant": 2.0, + "active": 1, + } + rows.append(row) + tracker.update( + distance_m=distance, + duration_s=timing.sample_duration_s, + active=1, + reported_speed_mps=2.0, + ) + + finalized = finalize_workout(Table(list(rows[0]), rows)) + + assert finalized.total_elapsed_time_ms == 30_000 + assert finalized.total_timer_time_ms == 3_000 + assert len(finalized.segments) == 2 + assert tracker.active_time_s == finalized.total_timer_time_ms / 1000 + assert tracker.active_distance_m == finalized.total_distance_m == 6.0 diff --git a/tests/test_recovery.py b/tests/test_recovery.py new file mode 100644 index 0000000..8b3a7a9 --- /dev/null +++ b/tests/test_recovery.py @@ -0,0 +1,94 @@ +""" +Tests for the crash/interruption recovery marker. + +Run from the project root: + + python tests/test_recovery.py +""" + +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app import recovery +from app.workout_paths import WorkoutPaths + + +def _use_temp_marker(tmp: Path) -> None: + """Point the module's marker at a temp file for the duration of a test.""" + recovery._MARKER_PATH = tmp / ".active_workout.json" + + +def _write_csv(path: Path, rows: int) -> None: + lines = ["timestamp;distance"] + for i in range(rows): + lines.append(f"{1000 * i};{3.0 * i}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def test_no_marker_means_no_recovery() -> None: + with tempfile.TemporaryDirectory() as d: + _use_temp_marker(Path(d)) + assert recovery.pending_recovery() is None + print(" ok: clean state offers no recovery") + + +def test_marker_with_data_is_recoverable() -> None: + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + _use_temp_marker(tmp) + workout_dir = tmp / "workout_20260714_120000" + workout_dir.mkdir() + csv_path = WorkoutPaths(workout_dir).csv + _write_csv(csv_path, rows=20) + recovery.mark_active(workout_dir) + + info = recovery.pending_recovery() + assert info is not None, "expected a recoverable workout" + assert info["csv_path"] == str(csv_path) + print(" ok: interrupted workout with data is recoverable") + + +def test_marker_with_empty_csv_self_heals() -> None: + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + _use_temp_marker(tmp) + workout_dir = tmp / "workout_20260714_120000" + workout_dir.mkdir() + csv_path = WorkoutPaths(workout_dir).csv + csv_path.write_text("timestamp;distance\n", encoding="utf-8") # header only + recovery.mark_active(workout_dir) + + assert recovery.pending_recovery() is None, "header-only CSV is not recoverable" + assert not recovery._MARKER_PATH.exists(), "stale marker should self-clear" + print(" ok: header-only CSV clears the marker") + + +def test_marker_with_missing_csv_self_heals() -> None: + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + _use_temp_marker(tmp) + recovery.mark_active(tmp / "workout_20260714_120000") + + assert recovery.pending_recovery() is None + assert not recovery._MARKER_PATH.exists() + print(" ok: missing CSV clears the marker") + + +def test_clear_active_removes_marker() -> None: + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + _use_temp_marker(tmp) + workout_dir = tmp / "workout_20260714_120000" + workout_dir.mkdir() + csv_path = WorkoutPaths(workout_dir).csv + _write_csv(csv_path, rows=5) + recovery.mark_active(workout_dir) + assert recovery._MARKER_PATH.exists() + + recovery.clear_active() + assert not recovery._MARKER_PATH.exists() + assert recovery.pending_recovery() is None + print(" ok: clear_active removes the marker") diff --git a/tests/test_reliability.py b/tests/test_reliability.py new file mode 100644 index 0000000..6cf4bb6 --- /dev/null +++ b/tests/test_reliability.py @@ -0,0 +1,183 @@ +"""Reliability contracts for atomic state, configuration, and workers.""" + +import asyncio +import json +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from app import recovery +from app.atomic_json import write_atomic_json +from app.ble_device import BleDevice +from app.events import UiEvent +from app.recording_timeline import RecordingTimeline +from app.worker_result import WorkerResult +from gui.config_manager import ConfigError, ConfigManager +from gui.worker_manager import WorkerManager + + +def test_worker_result_has_one_explicit_terminal_outcome() -> None: + result = WorkerResult( + outcome="partial", + stage="persistence", + message="disk full", + csv_path="workout.csv", + durable_rows=42, + retryable=True, + ) + event = result.to_event("workout_result") + assert event["outcome"] == "partial" + assert event["success"] is False + assert event["pending"] is False + assert event["durable_rows"] == 42 + + +def test_atomic_json_preserves_previous_file_when_replace_fails() -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "state.json" + path.write_text('{"version": 1}', encoding="utf-8") + with ( + patch("app.atomic_json.os.replace", side_effect=OSError("disk error")), + pytest.raises(OSError, match="disk error"), + ): + write_atomic_json(path, {"version": 2}) + assert json.loads(path.read_text(encoding="utf-8")) == {"version": 1} + assert not path.with_name("state.json.tmp").exists() + + +def test_recovery_marker_failure_is_not_swallowed() -> None: + with tempfile.TemporaryDirectory() as tmp, patch.object( + recovery, "_MARKER_PATH", Path(tmp) / "active.json" + ), patch( + "app.recovery.write_atomic_json", side_effect=OSError("no space") + ), pytest.raises(OSError, match="no space"): + recovery.mark_active("workout.csv") + + +def test_config_validation_rejects_invalid_explicit_values() -> None: + with tempfile.TemporaryDirectory() as tmp, patch.object(Path, "home", return_value=Path(tmp)): + manager = ConfigManager() + cfg = dict(manager.DEFAULTS) + cfg["ble_connect_retries"] = 1.5 + with pytest.raises(ConfigError, match="whole number"): + manager.validate_config(cfg) + + +def test_config_save_normalizes_platform_string_subclasses() -> None: + class PlatformString(str): + pass + + with tempfile.TemporaryDirectory() as tmp, patch.object(Path, "home", return_value=Path(tmp)): + manager = ConfigManager() + cfg = dict(manager.DEFAULTS) + cfg["ergometer_name"] = PlatformString("Kayak ABC") + cfg["ergometer_mac"] = PlatformString("9CE10062-4038-920C-8FB2-9C8A95AD36EC") + + manager.save_config(cfg) + loaded = manager.load_config() + + assert loaded["ergometer_name"] == "Kayak ABC" + assert loaded["ergometer_mac"] == "9CE10062-4038-920C-8FB2-9C8A95AD36EC" + assert type(loaded["ergometer_mac"]) is str + + +def test_invalid_config_is_preserved_and_reported() -> None: + with tempfile.TemporaryDirectory() as tmp, patch.object(Path, "home", return_value=Path(tmp)): + manager = ConfigManager() + manager.config_file.write_text("log_level: [broken", encoding="utf-8") + cfg = manager.load_config() + assert cfg["log_level"] == "info" + assert manager.config_warning is not None + assert not manager.config_file.exists() + assert len(list(manager.config_folder.glob("config.invalid-*.yml"))) == 1 + + +def test_queued_ergometer_packets_follow_source_time_without_overlapping() -> None: + timeline = RecordingTimeline(epoch_start_ms=100_000, monotonic_start_s=10.0) + first = timeline.advance( + device_elapsed_s=1, device_window_s=1, receipt_monotonic_s=20.0 + ) + delayed = timeline.advance( + device_elapsed_s=3, device_window_s=2, receipt_monotonic_s=20.0 + ) + stalled_counter = timeline.advance( + device_elapsed_s=3, device_window_s=1, receipt_monotonic_s=20.0 + ) + + assert (first.timestamp_ms, delayed.timestamp_ms) == (101_000, 103_000) + assert stalled_counter.timestamp_ms == 110_000 + assert stalled_counter.sample_duration_s == 1.0 + assert stalled_counter.gap_before_s == 6.0 + + reset_after_reconnect = timeline.advance( + device_elapsed_s=1, device_window_s=1, receipt_monotonic_s=120.0 + ) + assert reset_after_reconnect.timestamp_ms == 210_000 + assert reset_after_reconnect.session_elapsed_s == 110.0 + assert reset_after_reconnect.sample_duration_s == 1.0 + + +def test_runtime_config_can_validate_export_without_ergometer() -> None: + with tempfile.TemporaryDirectory() as tmp, patch.object(Path, "home", return_value=Path(tmp)): + manager = ConfigManager() + manager.save_config(dict(manager.DEFAULTS)) + assert manager.load_runtime_config(require_ergometer=False)["ergometer_mac"] == "" + with pytest.raises(ConfigError, match="No ergometer configured"): + manager.load_runtime_config() + + +def test_worker_manager_emits_exactly_one_workout_terminal_result() -> None: + class _Widget: + def after(self, _delay: int, callback: Callable[[], None]) -> None: + callback() + + events: list[UiEvent] = [] + manager = WorkerManager(lambda _message: None, _Widget()) + manager.set_event_callback(events.append) + + async def worker(**_kwargs: Any) -> WorkerResult: + return WorkerResult( + outcome="failed", stage="configuration", message="invalid config" + ) + + manager.workout_worker = worker + manager._run_workout_worker(None) + terminal = [event for event in events if event["type"] == "workout_result"] + assert len(terminal) == 1 + assert terminal[0]["outcome"] == "failed" + assert terminal[0]["success"] is False + + +def test_ble_open_link_cancellation_releases_partial_client() -> None: + class _Client: + is_connected = False + + def __init__(self, **_kwargs: Any) -> None: + self.disconnected = False + + async def connect(self) -> None: + raise asyncio.CancelledError + + async def disconnect(self) -> None: + self.disconnected = True + + created: list[_Client] = [] + + def make_client(**kwargs: Any) -> _Client: + client = _Client(**kwargs) + created.append(client) + return client + + device = BleDevice("test-address") + with ( + patch("app.ble_device.BleakClient", side_effect=make_client), + pytest.raises(asyncio.CancelledError), + ): + asyncio.run(device._open_link()) + + assert created[0].disconnected is True + assert device.client is None diff --git a/tests/test_secret_store.py b/tests/test_secret_store.py new file mode 100644 index 0000000..702384a --- /dev/null +++ b/tests/test_secret_store.py @@ -0,0 +1,86 @@ +"""Tests for atomic Strava credential-vault persistence.""" + +import json +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch + +from app import secret_store +from gui.config_manager import ConfigManager + + +class _FakeKeyring: + def __init__(self) -> None: + self.value: str | None = None + + def get_password(self, _service: str, _key: str) -> str | None: + return self.value + + def set_password(self, _service: str, _key: str, value: str) -> None: + self.value = value + + def delete_password(self, _service: str, _key: str) -> None: + self.value = None + + +def _install_fake_keyring(monkeypatch) -> _FakeKeyring: + fake = _FakeKeyring() + monkeypatch.setattr(secret_store, "keyring", fake) + monkeypatch.setattr(secret_store, "available", lambda: True) + monkeypatch.setattr(secret_store, "_cache", None) + return fake + + +def _credentials() -> dict[str, object]: + return { + "strava_client_id": "12345", + "strava_client_secret": "client-secret", + "strava_access_token": "access-token", + "strava_refresh_token": "refresh-token", + "strava_expires_at": 2_000_000_000, + } + + +def test_complete_credentials_round_trip_through_vault(monkeypatch) -> None: + fake = _install_fake_keyring(monkeypatch) + + assert secret_store.store_credentials(_credentials()) is True + assert fake.value is not None + + monkeypatch.setattr(secret_store, "_cache", None) + loaded = secret_store.load_credentials() + + assert loaded == _credentials() + assert secret_store.has_complete_credentials(loaded) + + +def test_incomplete_vault_entry_is_deleted_not_migrated(monkeypatch) -> None: + fake = _install_fake_keyring(monkeypatch) + fake.value = json.dumps({ + "strava_client_secret": "client-secret", + "strava_access_token": "access-token", + "strava_refresh_token": "refresh-token", + }) + + assert secret_store.load_credentials() == {} + assert fake.value is None + + +def test_complete_connection_survives_config_folder_deletion(monkeypatch) -> None: + _install_fake_keyring(monkeypatch) + + with tempfile.TemporaryDirectory() as tmp, patch.object(Path, "home", return_value=Path(tmp)): + manager = ConfigManager() + cfg = dict(manager.DEFAULTS) + cfg.update(_credentials()) + manager.save_config(cfg) + + shutil.rmtree(manager.config_folder) + monkeypatch.setattr(secret_store, "_cache", None) + + restored = ConfigManager().load_config() + + assert secret_store.has_complete_credentials(restored) + assert restored["strava_client_id"] == "12345" + assert restored["strava_expires_at"] == 2_000_000_000 diff --git a/tests/test_segmentation.py b/tests/test_segmentation.py new file mode 100644 index 0000000..669e140 --- /dev/null +++ b/tests/test_segmentation.py @@ -0,0 +1,92 @@ +"""Tests for the v1 ergometer-activity timer domain.""" + +import pytest + +from app.segmentation import LiveSegmenter, detect_segments + + +def _segments( + actives: list[int], windows: dict[int, tuple[int, int]] | None = None +): + windows = windows or {i: (i * 1000, (i + 1) * 1000) for i in range(len(actives))} + return detect_segments( + active_col=actives, + record_windows_ms=windows, + ) + + +def test_activity_flag_defines_exact_runs() -> None: + segments = _segments([1, 1, 0, 0, 1, 1]) + assert [(s["start_idx"], s["end_idx"]) for s in segments] == [(0, 1), (4, 5)] + + +def test_stale_speed_cannot_extend_activity() -> None: + segments = detect_segments( + active_col=[1, 1, 0, 0], + record_windows_ms={i: (i * 1000, (i + 1) * 1000) for i in range(4)}, + ) + assert segments[-1]["end_idx"] == 1 + + +def test_uncovered_gap_breaks_an_active_run() -> None: + windows = {0: (0, 1000), 1: (1000, 2000), 2: (9000, 10_000), 3: (10_000, 11_000)} + segments = _segments([1, 1, 1, 1], windows) + assert [(s["start_idx"], s["end_idx"]) for s in segments] == [(0, 1), (2, 3)] + + +def test_missing_or_invalid_activity_is_rejected() -> None: + with pytest.raises((TypeError, ValueError)): + detect_segments(active_col=None, record_windows_ms={0: (0, 1000)}) + with pytest.raises(ValueError, match="invalid activity"): + detect_segments(active_col=[None], record_windows_ms={0: (0, 1000)}) + with pytest.raises(ValueError, match="invalid activity"): + detect_segments(active_col=[True], record_windows_ms={0: (0, 1000)}) + + +def test_live_short_inactivity_does_not_pause_or_advance_lap() -> None: + segmenter = LiveSegmenter() + transitions = [] + for active in [1, 0, 1, 0, 0, 0, 0, 1]: + state = segmenter.update(active=active) + if state["transition"]: + transitions.append(state["transition"]) + assert transitions == ["start"] + assert state == {"paused": False, "lap": 1, "transition": None} + + +def test_live_pause_requires_five_canonical_seconds() -> None: + segmenter = LiveSegmenter() + states = [segmenter.update(active=1)] + states.extend(segmenter.update(active=0) for _ in range(5)) + states.append(segmenter.update(active=1)) + + assert [state["transition"] for state in states] == [ + "start", None, None, None, None, "pause", "resume" + ] + assert states[-1] == {"paused": False, "lap": 2, "transition": "resume"} + + +def test_live_pause_uses_sample_duration_not_packet_count() -> None: + segmenter = LiveSegmenter() + segmenter.update(active=1) + paused = segmenter.update(active=0, sample_duration_s=5) + assert paused == {"paused": True, "lap": 1, "transition": "pause"} + + +def test_live_missing_activity_is_rejected() -> None: + with pytest.raises(ValueError, match="activity signal"): + LiveSegmenter().update(active=None) + + +def test_live_gap_starts_a_new_lap_without_inflating_time() -> None: + segmenter = LiveSegmenter() + assert segmenter.update(active=1)["lap"] == 1 + resumed = segmenter.update(active=1, gap_before_s=30) + assert resumed == {"paused": False, "lap": 2, "transition": "resume"} + + +def test_live_short_gap_stays_in_current_lap() -> None: + segmenter = LiveSegmenter() + assert segmenter.update(active=1)["lap"] == 1 + continued = segmenter.update(active=1, gap_before_s=4.999) + assert continued == {"paused": False, "lap": 1, "transition": None} diff --git a/tests/test_speed_series.py b/tests/test_speed_series.py new file mode 100644 index 0000000..bf283d1 --- /dev/null +++ b/tests/test_speed_series.py @@ -0,0 +1,175 @@ +""" +Tests for the live speed preview (app.speed_series.LiveSpeedTracker). + +The tracker uses the same recorded-point policy as batch finalization: +instantaneous speed is time-weighted over active intervals, its raw peak is the +maximum, and cumulative distance is accumulated independently. + +The finalized (post-workout) series itself is pinned end-to-end by +tests/test_summary.py and tests/test_pipeline.py; this file covers only the +streaming preview, which has no other test surface (the GUI is untested). +""" + +from app.finalized_workout import finalize_workout +from app.speed_series import LiveSpeedTracker +from app.summary import compute_summary +from app.table import Table + + +def _feed( + tracker: LiveSpeedTracker, + cumulative_m: float | None, + active: int = 1, + duration_s: float = 1.0, + reported_mps: float | None = 2.0, +) -> None: + tracker.update( + distance_m=cumulative_m, + duration_s=duration_s, + active=active, + reported_speed_mps=reported_mps, + ) + + +def test_avg_and_max_use_recorded_instantaneous_speed(): + """Six slow seconds (2 m) then six fast seconds (4 m): avg 3.0, max 4.0.""" + tracker = LiveSpeedTracker() + cumulative = 0.0 + for _ in range(6): + cumulative += 2.0 + _feed(tracker, cumulative, reported_mps=2.0) + for _ in range(6): + cumulative += 4.0 + _feed(tracker, cumulative, reported_mps=4.0) + + assert tracker.avg_speed_mps == 3.0 + assert tracker.max_speed_mps == 4.0 + + +def test_single_sample_sensor_peak_is_reported_verbatim(): + """Canonical max is the largest active instantaneous speed sample.""" + tracker = LiveSpeedTracker() + cumulative = 0.0 + for step in (2.0, 2.0, 2.0, 10.0, 2.0, 2.0, 2.0): + cumulative += step + _feed(tracker, cumulative, reported_mps=step) + + assert tracker.max_speed_mps is not None + assert tracker.max_speed_mps == 10.0 + + +def test_pause_excludes_idle_time_and_distance(): + """Idle samples add neither average denominator nor active distance.""" + tracker = LiveSpeedTracker() + cumulative = 0.0 + for _ in range(5): # 10 m in 5 s + cumulative += 2.0 + _feed(tracker, cumulative, reported_mps=2.0) + cumulative += 30.0 # odometer drift while paused belongs to no lap + for _ in range(3): + _feed(tracker, cumulative, active=0) + cumulative += 50.0 # coarse resume jump remains recorded distance + _feed(tracker, cumulative, reported_mps=4.0) + for _ in range(4): # steady 4 m/s + cumulative += 4.0 + _feed(tracker, cumulative, reported_mps=4.0) + + assert tracker.active_time_s == 10.0 # 3 idle seconds excluded + assert tracker.active_distance_m == 76.0 # 10 + 50 + 16 + assert tracker.avg_speed_mps == 3.0 + assert tracker.max_speed_mps == 4.0 + + +def test_dropped_odometer_reading_is_bridged_not_spiked(): + """A missing distance cell defers its span to the next valid reading.""" + tracker = LiveSpeedTracker() + _feed(tracker, 2.0, reported_mps=2.0) + _feed(tracker, None, reported_mps=2.0) + _feed(tracker, 6.0, reported_mps=2.0) + + assert tracker.active_distance_m == 6.0 + assert tracker.active_time_s == 3.0 + assert tracker.max_speed_mps is not None + assert tracker.max_speed_mps <= 2.0 + + +def test_slow_active_spans_remain_valid_measurements(): + """The activity signal, not an arbitrary speed floor, owns measurements.""" + tracker = LiveSpeedTracker() + cumulative = 0.0 + for _ in range(5): + cumulative += 0.2 + _feed(tracker, cumulative, reported_mps=0.2) + assert tracker.max_speed_mps is not None + assert abs(tracker.max_speed_mps - 0.2) < 1e-9 + + for _ in range(3): + cumulative += 4.0 + _feed(tracker, cumulative, reported_mps=4.0) + assert tracker.max_speed_mps == 4.0 + # The slow active seconds still count in the average's denominator, + # exactly like finalized timer time. + assert tracker.avg_speed_mps == (1.0 + 12.0) / 8.0 + + +def test_resume_distance_is_not_rewritten_by_speed(): + """Recorded odometer jumps are independent of sensor speed statistics.""" + fresh = LiveSpeedTracker() + _feed(fresh, 3.0, reported_mps=0.0) # a wrong cap would zero this + assert fresh.active_distance_m == 3.0 + + resumed = LiveSpeedTracker() + _feed(resumed, 0.0, active=0) + _feed(resumed, 50.0, reported_mps=3.0) + assert resumed.active_distance_m == 50.0 + + +def test_zero_duration_active_observation_only_updates_the_baseline() -> None: + tracker = LiveSpeedTracker() + _feed(tracker, 10.0, duration_s=0, reported_mps=5.0) + _feed(tracker, 12.0, duration_s=1, reported_mps=2.0) + + assert tracker.active_time_s == 1.0 + assert tracker.active_distance_m == 2.0 + assert tracker.avg_speed_mps == tracker.max_speed_mps == 2.0 + + +def test_live_and_finalized_resume_distance_use_the_same_raw_odometer(): + columns = [ + "timestamp", "session_elapsed", "heart_rate", "cadence", "cadence_instant", "distance", + "speed", "speed_instant", "pull_force", "pull_force_instant", "active", "sample_duration", + ] + rows = [ + dict(zip(columns, [1000, 1, 120, 60, 60, 3.0, 3.0, 3.0, 100, 100, 1, 1], strict=True)), + dict(zip(columns, [2000, 2, 120, 0, 0, 3.0, 0.0, 0.0, 0, 0, 0, 1], strict=True)), + dict(zip(columns, [3000, 3, 120, 60, 60, 13.0, 1.0, 3.0, 100, 100, 1, 1], strict=True)), + ] + tracker = LiveSpeedTracker() + for row in rows: + tracker.update( + distance_m=row["distance"], + duration_s=row["sample_duration"], + active=row["active"], + reported_speed_mps=row["speed_instant"], + ) + + finalized = finalize_workout(Table(columns, rows)) + assert tracker.active_distance_m == finalized.total_distance_m == 13.0 + assert tracker.active_time_s == finalized.total_timer_time_ms / 1000.0 == 2.0 + assert tracker.avg_speed_mps == 3.0 + + +def test_zero_distance_active_window_reports_zero_average_and_maximum(): + columns = [ + "timestamp", "session_elapsed", "heart_rate", "cadence", "cadence_instant", "distance", + "speed_instant", "pull_force", "pull_force_instant", "active", "sample_duration", + ] + row = dict( + zip(columns, [1000, 1, 120, 60, 60, 0.0, 0.0, 100, 100, 1, 1], strict=True) + ) + tracker = LiveSpeedTracker() + tracker.update(distance_m=0.0, duration_s=1, active=1, reported_speed_mps=0.0) + summary = compute_summary(Table(columns, [row])) + + assert tracker.avg_speed_mps == summary.avg_speed_mps == 0.0 + assert tracker.max_speed_mps == summary.max_speed_mps == 0.0 diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..ad3a59b --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,192 @@ +""" +Tests for the shared workout-metric aggregation and finalized summary metrics. + +Two behaviours are pinned: + + * HR / cadence / power average over strictly-positive readings only (a 0 is a + dropped sensor sample, not a real zero), so a run of 0 readings does NOT + drag the average down. + * Average speed is the represented-time-weighted mean of recorded speed. +""" + +from app.stats import aggregate_metrics +from app.summary import compute_summary +from app.table import Table + +COLUMNS = [ + "timestamp", "session_elapsed", "heart_rate", "cadence", "cadence_instant", "distance", + "speed_instant", "pull_force", "pull_force_instant", "active", "sample_duration", +] +T0 = 1_700_000_000_000 + + +# -------------------------------------------------------------------------- +# app.stats.aggregate_metrics -- the shared source of truth +# -------------------------------------------------------------------------- +def test_positive_filtering_and_avg_speed(): + # Ten real HR readings (150) and ten dropped ones (0). + hr = [150] * 10 + [0] * 10 + cadence = [60] * 10 + [0] * 10 + pull_force = [100] * 20 + speed = [2.5] * 10 + [0.0] * 10 + + m = aggregate_metrics( + heart_rate=hr, + cadence=cadence, + power_cadence=cadence, + power_pull_force=pull_force, + speed=speed, + sample_weights=[1.0] * 20, + ) + # Zeros excluded: average is 150 / 60, not 75 / 30. + assert m["avg_heart_rate"] == 150.0, m["avg_heart_rate"] + assert m["max_heart_rate"] == 150.0 + assert m["avg_cadence"] == 60.0, m["avg_cadence"] + # power = 100 * 0.600 * 60 / 60 = 60 W for the moving rows; the cadence-0 + # rows estimate 0 W and are excluded. + assert m["avg_power"] == 60.0, m["avg_power"] + assert m["max_power"] == 60.0 + # Peak speed over positive samples. + assert m["max_speed"] == 2.5 + # Speed is reproduced directly from the recorded samples. + assert m["avg_speed"] == 1.25 + print("test_positive_filtering_and_avg_speed passed") + + +def test_all_missing_returns_none(): + m = aggregate_metrics( + heart_rate=[None, 0, None], + cadence=[0, 0], + speed=[0, 0], + sample_weights=[1.0, 1.0, 1.0], + ) + assert m["avg_heart_rate"] is None + assert m["max_heart_rate"] is None + assert m["avg_cadence"] is None + assert m["avg_power"] is None + assert m["max_speed"] == 0.0 + assert m["avg_speed"] == 0.0 + print("test_all_missing_returns_none passed") + + +def test_invalid_recorded_speed_does_not_fall_back_to_distance(): + metrics = aggregate_metrics( + speed=[None, "invalid"], + sample_weights=[1.0, 1.0], + ) + + assert metrics["avg_speed"] is None + assert metrics["max_speed"] is None + + +def test_generic_aggregator_does_not_rewrite_an_independent_peak() -> None: + metrics = aggregate_metrics(speed=[2.70, 2.71], sample_weights=[1.0, 1.0]) + + assert metrics["avg_speed"] is not None + assert metrics["max_speed"] == 2.71 + assert metrics["avg_speed"] == (2.70 + 2.71) / 2 + + +def test_sensor_averages_are_weighted_by_sample_window(): + metrics = aggregate_metrics( + heart_rate=[100, 200], + cadence=[50, 100], + power_cadence=[50, 100], + power_pull_force=[100, 100], + sample_weights=[1, 3], + ) + assert metrics["avg_heart_rate"] == 175.0 + assert metrics["avg_cadence"] == 87.5 + # Power estimates are 50 W and 100 W with the configured pull length. + assert metrics["avg_power"] == 87.5 + print("test_sensor_averages_are_weighted_by_sample_window passed") + + +# -------------------------------------------------------------------------- +# summary <-> FIT exporter parity for the same workout +# -------------------------------------------------------------------------- +def _workout_with_dropouts() -> Table: + """One continuous moving lap: 10s of full signal, then 10s of HR/cadence + dropouts (0 readings) while still moving.""" + rows = [] + for i in range(20): + good = i < 10 + rows.append( + { + "timestamp": T0 + (i + 1) * 1000, + "session_elapsed": i + 1, + "heart_rate": 150 if good else 0, + "cadence": 60 if good else 0, + "cadence_instant": 60 if good else 0, + "distance": 2.5 * (i + 1), + "speed_instant": 2.5, + "pull_force": 100, + "pull_force_instant": 100, + "active": 1, + "sample_duration": 1, + } + ) + return Table(COLUMNS, rows) + + +def test_finalized_summary_excludes_dropouts(): + table = _workout_with_dropouts() + summary = compute_summary(table) + + # Single continuous lap covering the whole workout. + assert len(summary.laps) == 1 + assert abs(summary.distance_m - 50.0) < 1e-9 + assert abs(summary.active_time_s - 20.0) < 1e-9 + + # Zeros are excluded: HR average is 150, not 75. + assert summary.avg_hr == 150.0 + + # Cadence: summary reports avg_spm, exporter avg_cadence; both exclude 0s. + assert summary.avg_spm == 60.0 + + # Power: 60 W on both sides (drop rows estimate 0 W and are excluded). + assert summary.avg_power == 60.0 + + # Constant recorded speed remains 2.5 m/s. + assert summary.avg_speed_mps is not None + assert abs(summary.avg_speed_mps - 2.5) < 1e-9 + print("test_finalized_summary_excludes_dropouts passed") + + +def test_avg_speed_is_the_recorded_sample_mean(): + """Finalized speed stays reproducible from the recorded speed points.""" + # Speeds ramp 1..10 m/s but distance accumulates by the *previous* speed, + # so the mean of samples (5.5) is not the physical average speed. + rows = [] + dist = 0.0 + for i in range(10): + rows.append( + { + "timestamp": T0 + (i + 1) * 1000, + "session_elapsed": i + 1, + "heart_rate": 140, + "cadence": 70, + "cadence_instant": 70, + "distance": dist, + "speed_instant": float(i + 1), + "pull_force": 120, + "pull_force_instant": 120, + "active": 1, + "sample_duration": 1, + } + ) + dist += float(i + 1) # advance by this second's speed + table = Table(COLUMNS, rows) + + summary = compute_summary(table) + covered = summary.distance_m + duration = summary.active_time_s + distance_time_speed = covered / duration + + # Sample mean of the speed column would be (1+..+10)/10 = 5.5; the physical + # average is different. + assert abs(distance_time_speed - 5.5) > 0.1, distance_time_speed + assert summary.avg_speed_mps is not None + assert summary.avg_speed_mps == 5.5 + + print("test_avg_speed_is_the_recorded_sample_mean passed") diff --git a/tests/test_strava.py b/tests/test_strava.py new file mode 100644 index 0000000..f7ffdfd --- /dev/null +++ b/tests/test_strava.py @@ -0,0 +1,453 @@ +""" +Tests for the direct-``requests`` Strava layer (app/strava_api.py) and the +uploader/auth flows built on it (app/strava_uploader.py, app/strava_auth.py), +after removing the ``stravalib`` dependency. + +All HTTP is mocked -- ``app.strava_api.requests`` is swapped for a fake that +routes calls to a per-test handler. No network access and no real Strava +credentials are used (and none are available in this sandbox); see the +NOTE at the bottom about real end-to-end verification. + +Run from the project root: + + python tests/test_strava.py +""" + +import http.client +import sys +import tempfile +import threading +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import requests + +import app.strava_uploader as strava_uploader +from app import strava_api +from app.strava_auth import StravaAuth, _CallbackHandler +from app.strava_uploader import StravaUploader + +_UNSET = object() + + +class FakeResponse: + """Stand-in for requests.Response.""" + + def __init__(self, status=200, json_data=_UNSET, text=""): + self.status_code = status + self._json = json_data + self.text = text + self.ok = 200 <= status < 300 + + def json(self): + if self._json is _UNSET: + raise ValueError("No JSON object could be decoded") + return self._json + + +class FakeRequests: + """Drop-in for the ``requests`` module used inside app.strava_api.""" + + exceptions = requests.exceptions # real exception classes + + def __init__(self, router): + self._router = router + self.calls = [] + + def request(self, method, url, headers=None, data=None, files=None, timeout=None): + self.calls.append( + {"method": method, "url": url, "headers": headers, "data": data, "files": files} + ) + return self._router(self, method, url, data) + + +def _install(router): + """Swap in a FakeRequests; return the fake and the original for restore.""" + original = strava_api.requests + fake = FakeRequests(router) + strava_api.requests = fake # type: ignore[assignment] + return fake, original + + +# -------------------------------------------------------------------------- +# strava_api: URL building + token endpoints +# -------------------------------------------------------------------------- +def test_build_authorize_url(): + url = strava_api.build_authorize_url( + client_id="12345", + redirect_uri="http://127.0.0.1:8000", + scope=["activity:write", "activity:read"], + state="abc123", + ) + assert url.startswith("https://www.strava.com/oauth/authorize?") + q = parse_qs(urlparse(url).query) + assert q["client_id"] == ["12345"] + assert q["redirect_uri"] == ["http://127.0.0.1:8000"] + assert q["response_type"] == ["code"] + assert q["approval_prompt"] == ["auto"] + # Scope is comma-joined (matching stravalib / Strava's API). + assert q["scope"] == ["activity:write,activity:read"] + assert q["state"] == ["abc123"] + print("test_build_authorize_url passed") + + +def test_build_authorize_url_bad_client_id(): + try: + strava_api.build_authorize_url( + client_id="not-a-number", + redirect_uri="http://127.0.0.1:8000", + scope=["activity:write"], + state="s", + ) + except (ValueError, TypeError): + print("test_build_authorize_url_bad_client_id passed") + return + raise AssertionError("expected ValueError/TypeError for non-numeric client id") + + +def test_oauth_error_callback_requires_matching_state(): + server = StravaAuth._start_server(start_port=18000, max_attempts=20) + assert server is not None + _CallbackHandler.auth_error = None + _CallbackHandler.expected_state = "expected" + + thread = threading.Thread(target=server.handle_request) + thread.start() + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=2) + try: + connection.request("GET", "/?error=access_denied&state=wrong") + response = connection.getresponse() + assert response.status == 400 + response.read() + finally: + connection.close() + thread.join(timeout=2) + server.server_close() + + assert _CallbackHandler.auth_error is None + + +def test_exchange_code_success(): + def router(fake, method, url, data): + assert method == "POST" and url == strava_api.TOKEN_URL + assert data["grant_type"] == "authorization_code" + assert data["code"] == "goodcode" + return FakeResponse( + 200, + { + "access_token": "AT", + "refresh_token": "RT", + "expires_at": 1_800_000_000, + "extra": "ignored", + }, + ) + + _, original = _install(router) + try: + tokens = strava_api.exchange_code_for_token("123", "secret", "goodcode") + assert tokens == { + "access_token": "AT", + "refresh_token": "RT", + "expires_at": 1_800_000_000, + } + finally: + strava_api.requests = original + print("test_exchange_code_success passed") + + +def test_exchange_code_rejected(): + """An expired/invalid code -> Strava 400 -> StravaApiError with detail.""" + + def router(fake, method, url, data): + return FakeResponse( + 400, + { + "message": "Bad Request", + "errors": [ + {"resource": "AuthorizationCode", "field": "code", "code": "invalid"} + ], + }, + ) + + _, original = _install(router) + try: + try: + strava_api.exchange_code_for_token("123", "secret", "expired") + except strava_api.StravaApiError as e: + msg = str(e) + assert "400" in msg and "invalid" in msg, msg + else: + raise AssertionError("expected StravaApiError for a rejected code") + finally: + strava_api.requests = original + print("test_exchange_code_rejected passed") + + +def test_refresh_success(): + def router(fake, method, url, data): + assert data["grant_type"] == "refresh_token" + assert data["refresh_token"] == "RT" + return FakeResponse( + 200, + {"access_token": "AT2", "refresh_token": "RT2", "expires_at": 1_900_000_000}, + ) + + _, original = _install(router) + try: + tokens = strava_api.refresh_access_token("123", "secret", "RT") + assert tokens["access_token"] == "AT2" + assert tokens["refresh_token"] == "RT2" + assert tokens["expires_at"] == 1_900_000_000 + finally: + strava_api.requests = original + print("test_refresh_success passed") + + +def test_network_error_becomes_api_error(): + def router(fake, method, url, data): + raise requests.exceptions.ConnectionError("boom") + + _, original = _install(router) + try: + try: + strava_api.refresh_access_token("123", "secret", "RT") + except strava_api.StravaApiError as e: + assert "Network error" in str(e) + else: + raise AssertionError("expected StravaApiError on network failure") + finally: + strava_api.requests = original + print("test_network_error_becomes_api_error passed") + + +def test_rate_limit_becomes_api_error(): + def router(fake, method, url, data): + return FakeResponse(429, {"message": "Rate Limit Exceeded"}) + + _, original = _install(router) + try: + try: + strava_api.refresh_access_token("123", "secret", "RT") + except strava_api.StravaApiError as e: + assert "429" in str(e) + else: + raise AssertionError("expected StravaApiError on HTTP 429") + finally: + strava_api.requests = original + print("test_rate_limit_becomes_api_error passed") + + +# -------------------------------------------------------------------------- +# StravaUploader: upload flow scenarios +# -------------------------------------------------------------------------- +def _uploader(access_token="AT", refresh_token="RT", expires_at=9_999_999_999): + return StravaUploader( + access_token=access_token, + refresh_token=refresh_token, + client_id="123", + client_secret="secret", + config={"log_level": "warning"}, + expires_at=expires_at, + ) + + +def _fit_file(tmpdir): + p = Path(tmpdir) / "workout.fit" + p.write_bytes(b"\x0e\x10FIT dummy bytes") + return p + + +def test_upload_success_poll_to_ready(): + state = {"polls": 0} + + def router(fake, method, url, data): + if url == strava_api.UPLOADS_URL and method == "POST": + return FakeResponse(201, {"id": 555, "status": "processing", "activity_id": None}) + if url.startswith(strava_api.UPLOADS_URL + "/") and method == "GET": + state["polls"] += 1 + if state["polls"] < 2: + return FakeResponse(200, {"id": 555, "error": None, "activity_id": None}) + return FakeResponse(200, {"id": 555, "error": None, "activity_id": 999}) + if url.startswith(strava_api.ACTIVITIES_URL + "/") and method == "GET": + return FakeResponse(200, {"id": 999, "name": "Kayak"}) + raise AssertionError(f"unexpected call {method} {url}") + + _, original = _install(router) + saved_interval = strava_uploader.UPLOAD_POLL_INTERVAL + strava_uploader.UPLOAD_POLL_INTERVAL = 0 + try: + with tempfile.TemporaryDirectory() as d: + result = _uploader().upload_file(str(_fit_file(d)), name="Kayak") + assert result["success"] is True, result + assert result["activity_id"] == 999, result + assert "Activity ID: 999" in result["message"] + assert state["polls"] >= 2 + finally: + strava_uploader.UPLOAD_POLL_INTERVAL = saved_interval + strava_api.requests = original + print("test_upload_success_poll_to_ready passed") + + +def test_workout_directory_is_the_strava_external_id(): + """Fixed ``activity.fit`` names must still produce unique uploads.""" + def router(fake, method, url, data): + if url == strava_api.UPLOADS_URL and method == "POST": + return FakeResponse(201, {"id": 12, "activity_id": 99}) + if url.startswith(strava_api.UPLOADS_URL + "/") and method == "GET": + return FakeResponse(200, {"id": 12, "error": None, "activity_id": 99}) + if url.startswith(strava_api.ACTIVITIES_URL + "/") and method == "GET": + return FakeResponse(200, {"id": 99, "name": "Kayak"}) + raise AssertionError(f"unexpected call {method} {url}") + + fake, original = _install(router) + try: + with tempfile.TemporaryDirectory() as d: + workout_dir = Path(d) / "workout_20260714_120000" + workout_dir.mkdir() + fit_path = workout_dir / "activity.fit" + fit_path.write_bytes(b"\x0e\x10FIT dummy bytes") + result = _uploader().upload_file(str(fit_path)) + assert result["success"] is True, result + assert fake.calls[0]["data"]["external_id"] == workout_dir.name + finally: + strava_api.requests = original + + +def test_upload_rejected(): + """Strava reports a processing error -> equivalent of ActivityUploadError.""" + + def router(fake, method, url, data): + if url == strava_api.UPLOADS_URL and method == "POST": + return FakeResponse(201, {"id": 7, "activity_id": None}) + if url.startswith(strava_api.UPLOADS_URL + "/") and method == "GET": + return FakeResponse( + 200, {"id": 7, "error": "Corrupt FIT file", "activity_id": None} + ) + raise AssertionError(f"unexpected call {method} {url}") + + _, original = _install(router) + strava_uploader.UPLOAD_POLL_INTERVAL = 0 + try: + with tempfile.TemporaryDirectory() as d: + result = _uploader().upload_file(str(_fit_file(d))) + assert result["success"] is False, result + assert "Strava rejected the upload" in result["message"] + assert "Corrupt FIT file" in result["message"] + finally: + strava_api.requests = original + print("test_upload_rejected passed") + + +def test_upload_timeout_still_processing(): + """Poll window elapses before Strava finishes -> success, still processing.""" + + def router(fake, method, url, data): + if url == strava_api.UPLOADS_URL and method == "POST": + return FakeResponse(201, {"id": 42, "activity_id": None}) + if url.startswith(strava_api.UPLOADS_URL + "/") and method == "GET": + return FakeResponse(200, {"id": 42, "error": None, "activity_id": None}) + raise AssertionError(f"unexpected call {method} {url}") + + _, original = _install(router) + saved_timeout = strava_uploader.UPLOAD_WAIT_TIMEOUT + strava_uploader.UPLOAD_WAIT_TIMEOUT = 0 # first poll then immediate timeout + strava_uploader.UPLOAD_POLL_INTERVAL = 0 + try: + with tempfile.TemporaryDirectory() as d: + result = _uploader().upload_file(str(_fit_file(d))) + assert result["success"] is True, result + assert result["activity_id"] is None + assert "still processing" in result["message"].lower() + finally: + strava_uploader.UPLOAD_WAIT_TIMEOUT = saved_timeout + strava_api.requests = original + print("test_upload_timeout_still_processing passed") + + +def test_upload_readback_fails_but_activity_exists(): + """Polling assigns an activity id, but the final /activities read-back + fails -> still treated as a successful upload (with the polled id).""" + + def router(fake, method, url, data): + if url == strava_api.UPLOADS_URL and method == "POST": + return FakeResponse(201, {"id": 8, "activity_id": None}) + if url.startswith(strava_api.UPLOADS_URL + "/") and method == "GET": + return FakeResponse(200, {"id": 8, "error": None, "activity_id": 314}) + if url.startswith(strava_api.ACTIVITIES_URL + "/") and method == "GET": + # e.g. token without activity:read scope, or eventual consistency. + return FakeResponse(403, {"message": "Authorization Error"}) + raise AssertionError(f"unexpected call {method} {url}") + + _, original = _install(router) + strava_uploader.UPLOAD_POLL_INTERVAL = 0 + try: + with tempfile.TemporaryDirectory() as d: + result = _uploader().upload_file(str(_fit_file(d))) + assert result["success"] is True, result + assert result["activity_id"] == 314, result + finally: + strava_api.requests = original + print("test_upload_readback_fails_but_activity_exists passed") + + +def test_upload_file_not_found(): + result = _uploader().upload_file("/no/such/file.fit") + assert result["success"] is False + assert "File not found" in result["message"] + print("test_upload_file_not_found passed") + + +def test_upload_token_refresh_failed(): + """No usable token and refresh impossible -> clean failure, no HTTP call.""" + + def router(fake, method, url, data): + raise AssertionError("no request should be made without a token") + + _, original = _install(router) + try: + with tempfile.TemporaryDirectory() as d: + uploader = _uploader(access_token="", refresh_token="", expires_at=0) + result = uploader.upload_file(str(_fit_file(d))) + assert result["success"] is False + assert "reconnect" in result["message"].lower() + finally: + strava_api.requests = original + print("test_upload_token_refresh_failed passed") + + +def test_uploader_refreshes_expired_token(): + """An expired access token is refreshed before uploading.""" + calls = {"refresh": 0} + + def router(fake, method, url, data): + if url == strava_api.TOKEN_URL and method == "POST": + calls["refresh"] += 1 + return FakeResponse( + 200, + {"access_token": "NEW", "refresh_token": "NEWRT", "expires_at": 9_999_999_999}, + ) + if url == strava_api.UPLOADS_URL and method == "POST": + # Must be using the refreshed token. + assert fake.calls[-1]["headers"]["Authorization"] == "Bearer NEW" + return FakeResponse(201, {"id": 1, "activity_id": None}) + if url.startswith(strava_api.UPLOADS_URL + "/") and method == "GET": + return FakeResponse(200, {"id": 1, "error": None, "activity_id": 77}) + if url.startswith(strava_api.ACTIVITIES_URL + "/") and method == "GET": + return FakeResponse(200, {"id": 77}) + raise AssertionError(f"unexpected call {method} {url}") + + _, original = _install(router) + strava_uploader.UPLOAD_POLL_INTERVAL = 0 + try: + with tempfile.TemporaryDirectory() as d: + uploader = _uploader(access_token="OLD", refresh_token="RT", expires_at=1) + result = uploader.upload_file(str(_fit_file(d))) + assert result["success"] is True, result + assert result["activity_id"] == 77 + assert calls["refresh"] == 1 + assert uploader.get_updated_tokens() == ("NEW", "NEWRT", 9_999_999_999) + finally: + strava_api.requests = original + print("test_uploader_refreshes_expired_token passed") diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..4ac4303 --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,463 @@ +""" +Tests for the post-workout summary computation (app/summary.py). + +Builds small synthetic workout Tables (same shape as CsvReader.read_all output) +and checks totals, laps, zone time, and chart series. + +Run from the project root: + + python tests/test_summary.py +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app.summary import compute_summary +from app.table import Table +from gui.summary_window import ( + _axis_bounds, + _downsample_indices, + _hr_zone_parts, + _MultiChart, +) + +COLUMNS = [ + "timestamp", "session_elapsed", "heart_rate", "cadence", "cadence_instant", "distance", + "speed_instant", "pull_force", "pull_force_instant", "active", "sample_duration", +] +T0 = 1_700_000_000_000 # arbitrary epoch ms + + +def test_planned_phase_strip_geometry(): + """Only time-based planned charts reserve the compact phase strip.""" + expected = _MultiChart._PHASE_H + _MultiChart._PHASE_GAP + assert _MultiChart._phase_space("time", has_steps=True) == expected + assert _MultiChart._phase_space("time", has_steps=False) == 0 + assert _MultiChart._phase_space("distance", has_steps=True) == 0 + + +def _row( + i, hr=None, cadence=60, distance=None, speed=2.5, pull_force=100, + ts=None, active=1, sample_duration=1, +): + endpoint_s = (ts if ts is not None else i) + 1 + return { + "timestamp": T0 + endpoint_s * 1000, + "session_elapsed": endpoint_s, + "heart_rate": hr, + "cadence": cadence, + "cadence_instant": cadence, + "distance": distance if distance is not None else 2.5 * (i + 1), + "speed_instant": speed, + "pull_force": pull_force, + "pull_force_instant": pull_force, + "active": active, + "sample_duration": sample_duration, + } + + +def _steady_table(n=60, hr=150): + """n seconds at 2.5 m/s, constant HR/cadence/force.""" + return Table(COLUMNS, [_row(i, hr=hr) for i in range(n)]) + + +def test_totals_and_series(): + n = 60 + s = compute_summary(_steady_table(n)) + + assert s.start_time is not None + assert abs(s.distance_m - 2.5 * n) < 1e-6 + # One continuous lap covering the whole workout. + assert len(s.laps) == 1 + assert abs(s.active_time_s - n) < 1e-6 + # Constant 2.5 m/s + assert s.avg_speed_mps is not None and s.max_speed_mps is not None + assert s.avg_hr is not None + lap0_avg_speed = s.laps[0].avg_speed_mps + assert lap0_avg_speed is not None + assert abs(s.avg_speed_mps - 2.5) < 1e-6 + assert abs(s.max_speed_mps - 2.5) < 1e-6 + assert abs(lap0_avg_speed - 2.5) < 1e-6 + assert abs(s.avg_hr - 150.0) < 1e-6 + assert s.max_hr == 150 + # power = 100 N * 0.600 m * 60 spm / 60 = 60 W + assert s.avg_power is not None and abs(s.avg_power - 60.0) < 1e-6 + assert s.max_power == 60 + # Series lengths all match record count. + assert ( + len(s.series_t) + == len(s.series_speed) + == len(s.series_hr) + == len(s.series_power) + == n + ) + assert s.series_t[0] == 1.0 and s.series_t[-1] == float(n) + assert s.series_time_bounds == (0.0, float(n)) + series_speed0 = s.series_speed[0] + assert series_speed0 is not None + assert abs(series_speed0 - 2.5) < 1e-6 + print("test_totals_and_series passed") + + +def test_two_laps_from_gap(): + """An explicit inactive record separates two free-workout laps.""" + rows = [_row(i) for i in range(30)] + rows.append(_row(30, active=0, speed=0, distance=75.0)) + # Second effort resumes later; the activity transition owns the split. + rows += [ + _row(30 + i, ts=90 + i, distance=2.5 * (30 + i)) for i in range(30) + ] + s = compute_summary(Table(COLUMNS, rows)) + + assert len(s.laps) == 2 + assert abs(s.laps[0].active_time_s - 30.0) < 1e-6 + assert abs(s.laps[1].active_time_s - 30.0) < 1e-6 + # Moving time excludes the 60s pause. + assert abs(s.active_time_s - 60.0) < 1e-6 + assert s.active_time_s == 60.0 + assert s.elapsed_time_s == 120.0 + assert s.pause_time_s == 60.0 + assert sum( + lap.elapsed_time_s + lap.pause_after_s for lap in s.laps + ) == s.elapsed_time_s + assert sum(lap.active_time_s for lap in s.laps) == s.active_time_s + # Lap 2 starts 90s after the workout start (wall clock). + assert abs(s.laps[1].start_s - 90.0) < 1e-6 + # Each lap covers its own distance. + assert abs(s.laps[0].distance_m - 2.5 * 30) < 1e-6 + print("test_two_laps_from_gap passed") + + +def test_split_speed_uses_recorded_signal_after_distance_jump(): + """A coarse odometer jump does not rewrite recorded speed statistics.""" + rows = [ + _row(0, distance=3.0, speed=3.0), + _row(1, distance=6.0, speed=3.0), + _row(2, distance=6.0, speed=0.0, active=0), + _row(3, distance=6.0, speed=0.0, active=0), + # The machine reports distance accumulated around the restart in one + # coarse update, while its recorded speed remains 3 m/s. + _row(4, ts=10, distance=18.0, speed=3.0), + _row(5, ts=11, distance=21.0, speed=3.0), + ] + summary = compute_summary(Table(COLUMNS, rows)) + + assert len(summary.laps) == 2 + assert summary.laps[1].distance_m == 15.0 + assert summary.laps[1].avg_speed_mps == 3.0 + assert summary.laps[1].avg_speed_mps <= max( + speed for speed in summary.series_speed if speed is not None + ) + + +def test_raw_speed_line_never_exceeds_reported_max(): + """The plotted raw speed points and cards use the same recorded channel.""" + rows = [_row(i, distance=_cumulative_distance(i), speed=1.0) for i in range(12)] + s = compute_summary(Table(COLUMNS, rows)) + + assert s.avg_speed_mps == 1.0 + assert s.max_speed_mps == 1.0 + + line = [v for v in s.series_speed if v is not None] + assert line, "expected a plotted speed line" + assert all(v <= s.max_speed_mps + 1e-9 for v in line) + assert max(line) == s.max_speed_mps + # A maximum is never below the average. + assert s.max_speed_mps >= s.avg_speed_mps + + +def _cumulative_distance(i: int) -> float: + """Cumulative metres at record ``i`` for 2 m/s (first 6 s) then 4 m/s.""" + slow = min(i + 1, 6) * 2.0 + fast = max(i - 5, 0) * 4.0 + return slow + fast + + +def test_overlapping_sample_windows_are_rejected(): + """Corrupt timing must not produce FIT timer time greater than elapsed time.""" + rows = [ + _row(0, sample_duration=1), + _row(1, sample_duration=2), + ] + + with pytest.raises(ValueError, match="sample windows overlap"): + compute_summary(Table(COLUMNS, rows)) + + +def test_chart_axis_includes_authoritative_maximum(): + _lo, hi = _axis_bounds( + [1.0, 2.0], floor=0.0, ceiling=5.0, authoritative_max=10.0 + ) + + assert hi > 10.0 + + +def test_brief_active_signal_gaps_do_not_fragment_chart_or_laps(): + """A sub-five-second inactive gap stays one lap and one continuous line. + + The two active bursts are separated by two inactive seconds, below the fixed + five-second meaningful pause, so they group into a single free lap with one + unbroken chart line. Inactive rows still contribute no active time and their + heart-rate spike (220) never enters the active maximum. + """ + columns = COLUMNS + actives = [1, 1, 1, 0, 0, 1, 1, 1] + rows = [] + distance = 0.0 + for i, active in enumerate(actives): + if active: + distance += 3.0 + rows.append( + { + **_row( + i, + hr=120 + i if active else 220, + distance=distance, + speed=3.0 if active else 0.0, + ), + "active": active, + "sample_duration": 1, + } + ) + summary = compute_summary(Table(columns, rows)) + assert summary.active_time_s == 6.0 + assert len(summary.laps) == 1 + assert len(summary.series_t) == 6 + assert summary.series_breaks == [True, False, False, False, False, False] + assert summary.max_hr == 127 + print(" ok: brief active-signal gaps do not fragment the chart or laps") + + +def test_run_start_anchor_matches_lap_distance_and_timer_start(): + """Each run's chart line begins at its start boundary on both axes (D020). + + Records are plotted at the end of their sample window, so without an anchor a + run's drawn line starts one window in and understates its span versus the lap + table. The anchors reintroduce the run-start boundary: the timer START (window + start time) and the cumulative distance preceding the first window — including + the odometer jump credited to the first active record after a pause. + """ + columns = COLUMNS + actives = [1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1] + # Odometer frozen through the pause, then a 4 m jump credited to the resume. + distances = [2, 4, 6, 6, 6, 6, 6, 6, 10, 12, 14] + rows = [ + { + **_row(i, hr=150, distance=float(distances[i]), + speed=3.0 if active else 0.0), + "active": active, + "sample_duration": 1, + } + for i, active in enumerate(actives) + ] + summary = compute_summary(Table(columns, rows)) + + # Two active runs split by a five-second pause; totals unchanged by anchors. + assert [round(lap.distance_m, 3) for lap in summary.laps] == [6.0, 8.0] + starts = [k for k, brk in enumerate(summary.series_breaks) if brk] + assert starts == [0, 3] + # Distance anchor is each run's start distance: 0 m, then the pre-jump 6 m. + assert summary.series_anchor_distance[0] == 0.0 + assert summary.series_anchor_distance[3] == 6.0 + # Time anchor is each run's timer START (window start), not the window end. + assert summary.series_anchor_t[0] == 0.0 + assert summary.series_anchor_t[3] == 8.0 + # Drawn span (anchor -> last point) now equals the lap distance per run. + first_end = summary.series_distance[2] + first_anchor = summary.series_anchor_distance[0] + second_end = summary.series_distance[5] + second_anchor = summary.series_anchor_distance[3] + assert first_end is not None and first_anchor is not None + assert second_end is not None and second_anchor is not None + assert first_end - first_anchor == 6.0 + assert second_end - second_anchor == 8.0 + print(" ok: run-start anchors align chart spans with the lap table") + + +def test_zone_time(): + """HR zone accumulation with default bounds (max_hr=200 -> Z bounds 0/120/140/160/180). + + Each record credits the delta to the next record of its segment; the final + record contributes nothing, so zone time sums to moving time exactly. + """ + rows = ( + [_row(i, hr=110) for i in range(0, 10)] # Z1 + + [_row(i, hr=150) for i in range(10, 20)] # Z3 + + [_row(i, hr=185) for i in range(20, 30)] # Z5 + ) + s = compute_summary(Table(COLUMNS, rows), max_hr=200) + + assert abs(sum(s.zone_seconds) - s.active_time_s) < 1e-6 + assert abs(sum(s.zone_seconds) - 30.0) < 1e-6 + assert abs(s.zone_seconds[0] - 10.0) < 1e-6 + assert abs(s.zone_seconds[2] - 10.0) < 1e-6 + assert abs(s.zone_seconds[4] - 10.0) < 1e-6 + assert s.zone_seconds[1] == 0.0 and s.zone_seconds[3] == 0.0 + print("test_zone_time passed") + + +def test_zone_time_excludes_pause_and_tail(): + """Zone time counts moving segments only, matching the FIT time domain. + + The HR strap keeps reporting during a mid-workout pause and through the + post-workout cooldown, but neither period may be credited to a zone: the + FIT file brackets the pause with STOP_ALL/START events and drops the + frozen-distance tail, so Garmin/Strava won't count them either. The zone + total must equal the moving time (both efforts), not the recording length. + """ + rows = [_row(i, hr=150) for i in range(30)] # effort 1 (Z3 @ max_hr=200) + # 20 s pause: HR still reporting high, but distance frozen and speed 0. + rows += [ + _row(30 + i, hr=165, distance=2.5 * 30, speed=0.0, cadence=0, + pull_force=0, active=0) + for i in range(20) + ] + # Effort 2 resumes: distance advances again. + rows += [ + _row(50 + i, hr=150, distance=2.5 * 30 + 2.5 * (i + 1)) for i in range(30) + ] + # Cooldown tail: strap keeps reporting (Z2) after the last stroke. + last_dist = 2.5 * 60 + rows += [ + _row(80 + i, hr=125, distance=last_dist, speed=0.0, cadence=0, + pull_force=0, active=0) + for i in range(15) + ] + s = compute_summary(Table(COLUMNS, rows), max_hr=200) + + assert len(s.laps) == 2, [(lap.start_s, lap.active_time_s) for lap in s.laps] + # Zone total == moving time, not the 95 s recording span. + assert abs(sum(s.zone_seconds) - s.active_time_s) < 1e-6, ( + sum(s.zone_seconds), + s.active_time_s, + ) + # The efforts (Z3) are counted; the pause (hr=165, Z4) and the cooldown + # tail (hr=125, Z2) are not credited at all. + assert s.zone_seconds[2] == max(s.zone_seconds) + assert s.zone_seconds[1] == 0.0, s.zone_seconds + assert s.zone_seconds[3] == 0.0, s.zone_seconds + print("test_zone_time_excludes_pause_and_tail passed") + + +def test_manual_zones(): + rows = [_row(i, hr=130) for i in range(10)] + # Manual bounds put 130 bpm in Z4 (>=125, <135). + s = compute_summary( + Table(COLUMNS, rows), zones=[80, 100, 115, 125, 135] + ) + assert s.zone_seconds[3] == max(s.zone_seconds) + print("test_manual_zones passed") + + +def test_missing_hr_and_force(): + """No HR strap and no force data: summary still works, optional fields None.""" + rows = [ + _row(i, hr=None, pull_force=None, cadence=None) for i in range(20) + ] + s = compute_summary(Table(COLUMNS, rows)) + assert s.avg_hr is None and s.max_hr is None + assert s.avg_power is None and s.max_power is None + assert s.avg_spm is None + assert sum(s.zone_seconds) == 0.0 + assert all(v is None for v in s.series_hr) + assert all(v is None for v in s.series_power) + assert abs(s.distance_m - 2.5 * 20) < 1e-6 + print("test_missing_hr_and_force passed") + + +def test_hr_zone_percentages_include_missing_signal_time(): + rows = [ + _row(i, hr=150 if i < 5 else None) + for i in range(10) + ] + summary = compute_summary(Table(COLUMNS, rows), max_hr=200) + parts, total = _hr_zone_parts(summary) + + assert total == summary.active_time_s == 10.0 + assert sum(seconds for _name, seconds, _color in parts) == total + assert parts[-1][0] == "No HR signal" + assert parts[-1][1] == 5.0 + + +def test_series_distance_matches_column(): + """series_distance mirrors the distance column, same length as series_t.""" + n = 60 + s = compute_summary(_steady_table(n)) + assert len(s.series_distance) == len(s.series_t) == n + # Distance column advances by 2.5 m in every represented window. + for i, d in enumerate(s.series_distance): + assert d is not None and abs(d - 2.5 * (i + 1)) < 1e-6 + print("test_series_distance_matches_column passed") + + +def test_series_distance_preserves_none(): + """Missing distance cells surface as None in the right positions and don't + disturb the other series.""" + rows = [_row(i) for i in range(20)] + # Blank out the distance reading on two records (an empty CSV cell parses + # to None) without touching anything else. + rows[5]["distance"] = None + rows[12]["distance"] = None + s = compute_summary(Table(COLUMNS, rows)) + + assert len(s.series_distance) == 20 + assert s.series_distance[5] is None + assert s.series_distance[12] is None + for i, d in enumerate(s.series_distance): + if i in (5, 12): + continue + assert d is not None and abs(d - 2.5 * (i + 1)) < 1e-6 + # Other series are unaffected by the missing distance cells. + assert len(s.series_t) == 20 and s.series_t[5] == 6.0 + assert all(v is not None for v in s.series_speed) + print("test_series_distance_preserves_none passed") + + +def test_empty_table_raises(): + try: + compute_summary(Table(COLUMNS, [])) + except ValueError: + print("test_empty_table_raises passed") + return + raise AssertionError("expected ValueError for empty table") + + +def test_all_inactive_table_raises(): + rows = [_row(i, active=0, speed=0, cadence=0, pull_force=0) for i in range(5)] + with pytest.raises(ValueError, match="no active ergometer"): + compute_summary(Table(COLUMNS, rows)) + + +def test_summary_excludes_inactive_heart_rate_tail(): + """The ergometer activity signal alone defines the final record.""" + rows = [_row(i, hr=140) for i in range(20)] # 20 moving records + frozen_dist = 2.5 * 20 + rows += [ + _row(20 + j, hr=138, distance=frozen_dist, speed=3.0, cadence=0, + pull_force=0, active=0) + for j in range(3) + ] + s = compute_summary(Table(COLUMNS, rows)) + assert len(s.laps) == 1 + assert s.laps[0].active_time_s == 20.0 + assert abs(s.laps[0].distance_m - frozen_dist) < 1e-6 + assert len(s.series_t) == 20 + assert s.series_t[-1] == 20.0 + assert s.active_time_s == 20.0 + + +def test_chart_downsampling_preserves_extrema_and_breaks(): + xs = [float(i) for i in range(10_000)] + ys = [100.0] * len(xs) + ys[4_321] = 999.0 + breaks = [False] * len(xs) + breaks[7_000] = True + indices = _downsample_indices(xs, ys, breaks, width=500) + assert len(indices) <= 2_100 + assert 4_321 in indices + assert 7_000 in indices + assert 0 in indices and 9_999 in indices diff --git a/tests/test_workout_connections.py b/tests/test_workout_connections.py new file mode 100644 index 0000000..34e33d6 --- /dev/null +++ b/tests/test_workout_connections.py @@ -0,0 +1,231 @@ +"""Regression coverage for workout-start BLE connection ordering.""" + +import asyncio +from typing import Any, cast +from unittest.mock import patch + +import pytest +from bleak import BleakScanner +from bleak.backends.device import BLEDevice + +from app import workout_session as workout_session_mod +from app.ble_device import BleDevice +from app.events import StatusPayload +from app.workout_session import WorkoutAbortedError, WorkoutSession +from gui.config_manager import ConfigManager + + +def _config(*, hrm_enabled: str = "yes") -> dict[str, Any]: + cfg = dict(ConfigManager.DEFAULTS) + cfg.update( + { + "ergometer_mac": "ERG-ADDRESS", + "hrm_mac": "HRM-ADDRESS", + "hrm_enabled": hrm_enabled, + "log_level": "error", + } + ) + return cfg + + +def _resolved_device() -> BLEDevice: + return cast(BLEDevice, object()) + + +def test_ble_device_uses_resolved_device() -> None: + resolved = _resolved_device() + received_target: list[object] = [] + + class _Client: + is_connected = True + + def __init__(self, *, address_or_ble_device: object, **_kwargs: Any) -> None: + received_target.append(address_or_ble_device) + + async def connect(self) -> None: + pass + + async def start_notify(self, **_kwargs: Any) -> None: + pass + + async def disconnect(self) -> None: + pass + + device = BleDevice("HRM-ADDRESS", ble_device=resolved) + with patch("app.ble_device.BleakClient", _Client): + asyncio.run(device._open_link()) + + assert received_target == [resolved] + + +def test_hrm_is_discovered_before_ergometer_connects(monkeypatch) -> None: + order: list[str] = [] + resolved = _resolved_device() + received_hrm_device: list[BLEDevice | None] = [] + + async def find_device(*_args: Any, **_kwargs: Any) -> BLEDevice: + order.append("discover_hrm") + return resolved + + class _Ergometer: + def __init__(self, **_kwargs: Any) -> None: + pass + + async def connect(self, **_kwargs: Any) -> None: + order.append("connect_ergometer") + + class _Hrm: + def __init__(self, *, ble_device: BLEDevice | None = None, **_kwargs: Any) -> None: + received_hrm_device.append(ble_device) + + async def connect(self, **_kwargs: Any) -> None: + order.append("connect_hrm") + + monkeypatch.setattr( + BleakScanner, "find_device_by_address", find_device + ) + monkeypatch.setattr(workout_session_mod, "KayakFirstErgometer", _Ergometer) + monkeypatch.setattr(workout_session_mod, "HeartRateMonitor", _Hrm) + + session = WorkoutSession(_config(), prompt_callback=lambda *_args: False) + asyncio.run(session._connect_devices()) + + assert order == ["discover_hrm", "connect_ergometer", "connect_hrm"] + assert received_hrm_device == [resolved] + + +def test_hrm_discovery_retries_once(monkeypatch) -> None: + resolved = _resolved_device() + results: list[BLEDevice | None] = [None, resolved] + + async def find_device(*_args: Any, **_kwargs: Any) -> BLEDevice | None: + return results.pop(0) + + monkeypatch.setattr( + BleakScanner, "find_device_by_address", find_device + ) + monkeypatch.setattr(workout_session_mod, "_HRM_DISCOVERY_RETRY_DELAY_S", 0.0) + + session = WorkoutSession(_config()) + found = asyncio.run(session._resolve_hrm_device("HRM-ADDRESS")) + + assert found is resolved + assert results == [] + + +def test_missing_hrm_can_continue(monkeypatch) -> None: + statuses: list[StatusPayload] = [] + ergometer_connected: list[bool] = [] + + async def find_device(*_args: Any, **_kwargs: Any) -> None: + return None + + class _Ergometer: + def __init__(self, **_kwargs: Any) -> None: + pass + + async def connect(self, **_kwargs: Any) -> None: + ergometer_connected.append(True) + + class _UnexpectedHrm: + def __init__(self, **_kwargs: Any) -> None: + raise AssertionError("HRM must not be created when discovery failed") + + monkeypatch.setattr( + BleakScanner, "find_device_by_address", find_device + ) + monkeypatch.setattr(workout_session_mod, "_HRM_DISCOVERY_RETRY_DELAY_S", 0.0) + monkeypatch.setattr(workout_session_mod, "KayakFirstErgometer", _Ergometer) + monkeypatch.setattr(workout_session_mod, "HeartRateMonitor", _UnexpectedHrm) + + session = WorkoutSession( + _config(), + status_callback=statuses.append, + prompt_callback=lambda *_args: True, + ) + asyncio.run(session._connect_devices()) + + assert ergometer_connected == [True] + assert session.hrm is None + assert {"event": "absent", "device": "hrm"} in statuses + + +def test_missing_hrm_can_cancel_before_ergometer_connects(monkeypatch) -> None: + async def find_device(*_args: Any, **_kwargs: Any) -> None: + return None + + class _UnexpectedErgometer: + def __init__(self, **_kwargs: Any) -> None: + raise AssertionError("ergometer must not be created after HRM cancellation") + + monkeypatch.setattr( + BleakScanner, "find_device_by_address", find_device + ) + monkeypatch.setattr(workout_session_mod, "_HRM_DISCOVERY_RETRY_DELAY_S", 0.0) + monkeypatch.setattr( + workout_session_mod, "KayakFirstErgometer", _UnexpectedErgometer + ) + + session = WorkoutSession(_config(), prompt_callback=lambda *_args: False) + with pytest.raises(WorkoutAbortedError, match="heart-rate monitor not reachable"): + asyncio.run(session._connect_devices()) + + +def test_disabled_hrm_does_not_scan(monkeypatch) -> None: + ergometer_connected: list[bool] = [] + + async def unexpected_scan(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("disabled HRM must not trigger discovery") + + class _Ergometer: + def __init__(self, **_kwargs: Any) -> None: + pass + + async def connect(self, **_kwargs: Any) -> None: + ergometer_connected.append(True) + + monkeypatch.setattr( + BleakScanner, "find_device_by_address", unexpected_scan + ) + monkeypatch.setattr(workout_session_mod, "KayakFirstErgometer", _Ergometer) + + session = WorkoutSession(_config(hrm_enabled="no")) + asyncio.run(session._connect_devices()) + + assert ergometer_connected == [True] + + +def test_stop_cancels_hrm_discovery(monkeypatch) -> None: + async def scenario() -> None: + scan_started = asyncio.Event() + + async def blocked_scan(*_args: Any, **_kwargs: Any) -> None: + scan_started.set() + await asyncio.Future() + + class _UnexpectedErgometer: + def __init__(self, **_kwargs: Any) -> None: + raise AssertionError("ergometer must not be created after Stop") + + monkeypatch.setattr( + BleakScanner, + "find_device_by_address", + blocked_scan, + ) + monkeypatch.setattr( + workout_session_mod, "KayakFirstErgometer", _UnexpectedErgometer + ) + + session = WorkoutSession(_config()) + task = asyncio.create_task( + session._run_until_stop( + session._connect_devices(), "connecting to devices" + ) + ) + await scan_started.wait() + session.stop_event.set() + + with pytest.raises(WorkoutAbortedError, match="connecting to devices"): + await task + + asyncio.run(scenario()) diff --git a/tests/test_workout_paths.py b/tests/test_workout_paths.py new file mode 100644 index 0000000..a525f14 --- /dev/null +++ b/tests/test_workout_paths.py @@ -0,0 +1,41 @@ +"""Tests for the single-directory workout storage contract.""" + +from datetime import datetime +from pathlib import Path + +from app.workout_paths import WorkoutPaths + + +def test_reserve_creates_fixed_artifact_directory_with_collision_suffix(tmp_path: Path) -> None: + started = datetime(2026, 7, 14, 12, 0, 0) + first = WorkoutPaths.reserve(tmp_path, started) + second = WorkoutPaths.reserve(tmp_path, started) + + assert first.directory.name == "workout_20260714_120000" + assert second.directory.name == "workout_20260714_120000_001" + assert first.csv == first.directory / "workout.csv" + assert first.metadata == first.directory / "metadata.json" + assert first.steps == first.directory / "steps.json" + assert first.fit == first.directory / "activity.fit" + assert first.fit_temp == first.directory / "activity.fit.tmp" + + +def test_owned_directory_validation_rejects_non_workout_paths(tmp_path: Path) -> None: + paths = WorkoutPaths.reserve(tmp_path, datetime(2026, 7, 14, 12, 0, 0)) + + assert paths.is_owned_by(tmp_path) + assert not WorkoutPaths(tmp_path / "2026").is_owned_by(tmp_path) + assert not WorkoutPaths(tmp_path / "elsewhere" / "workout_1").is_owned_by(tmp_path) + + +def test_from_csv_uses_fixed_artifact_names(tmp_path: Path) -> None: + directory = tmp_path / "workout_20260714_225154" + directory.mkdir() + csv_path = directory / "workout.csv" + csv_path.touch() + + paths = WorkoutPaths.from_csv(csv_path) + assert paths.csv == csv_path + assert paths.metadata == directory / "metadata.json" + assert paths.steps == directory / "steps.json" + assert paths.fit == directory / "activity.fit" diff --git a/tests/test_write_csv.py b/tests/test_write_csv.py new file mode 100644 index 0000000..f935c74 --- /dev/null +++ b/tests/test_write_csv.py @@ -0,0 +1,75 @@ +"""Tests for durable workout CSV writing and output-path reservation.""" + +import tempfile +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +import pytest + +from app import recovery +from app.workout_session import WorkoutSession +from app.write_csv import CsvWriteError, CsvWriter + + +def test_write_failure_is_terminal_and_does_not_increment_count() -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "workout.csv" + writer = CsvWriter(str(path)) + assert writer.writer is not None + with ( + patch.object(writer.writer, "writerow", side_effect=OSError("disk full")), + pytest.raises(CsvWriteError, match="disk full"), + ): + writer.write_record({"timestamp": 1}) + assert writer.failed + assert writer.records_written == 0 + with pytest.raises(CsvWriteError, match="earlier write failure"): + writer.write_record({"timestamp": 2}) + writer.close() + + +def test_successful_write_counts_only_flushed_rows() -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "workout.csv" + writer = CsvWriter(str(path)) + writer.write_record({"timestamp": 1, "distance__m": 3}) + assert writer.records_written == 1 + writer.close() + + +def test_session_reserves_unique_workout_directory_without_overwrite() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + marker = root / ".active_workout.json" + config = { + "output_dir": "workouts", + "log_level": "warning", + "pull_length_m": 0.600, + "max_hr": 185, + "hr_zone_mode": "auto", + "hr_zones": [], + } + started = datetime(2026, 7, 13, 12, 0, 0) + first = WorkoutSession(dict(config)) + second = WorkoutSession(dict(config)) + first.session_start = started + second.session_start = started + with ( + patch("app.workout_session.Path.home", return_value=root), + patch.object(recovery, "_MARKER_PATH", marker), + ): + first._setup_csv_writer() + second._setup_csv_writer() + assert first.csv_path is not None and second.csv_path is not None + assert first.csv_path != second.csv_path + assert Path(first.csv_path).name == "workout.csv" + assert Path(second.csv_path).name == "workout.csv" + assert Path(first.csv_path).parent.name == "workout_20260713_120000" + assert Path(second.csv_path).parent.name == "workout_20260713_120000_001" + assert Path(first.csv_path).parent != Path(second.csv_path).parent + assert (Path(first.csv_path).parent / "metadata.json").exists() + assert (Path(second.csv_path).parent / "metadata.json").exists() + assert first.csv_writer is not None and second.csv_writer is not None + first.csv_writer.close() + second.csv_writer.close() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0d97099 --- /dev/null +++ b/uv.lock @@ -0,0 +1,988 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "bitstruct" +version = "8.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/13/d8b56ef2e6b6ae2cc1b23e509ea7cc8d3423aa0d8a7d7634c23c0bf845e2/bitstruct-8.22.1.tar.gz", hash = "sha256:97588318c906c60d33129e0061dd830b03d793c517033d312487c75426d1e808", size = 35616, upload-time = "2026-02-17T14:14:23.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c4/d991d48c416578ad53d41cbdedd310b820fc07c6b9de9e8fc5a3c6b1de3e/bitstruct-8.22.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:098f6b66f89e3bbe320dd31b55094b02c51f365a8ee8f75c0c99e98833eae6a8", size = 38279, upload-time = "2026-02-17T14:13:49.178Z" }, + { url = "https://files.pythonhosted.org/packages/53/d1/0441ca2efb2730bb3a71fff5ef834f9a4cc3aef58c2fb6835ac01462d801/bitstruct-8.22.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2c5b1ef022b0d690b6707464ee1bfd394c79f9a9ef4e765faf1168056fa21337", size = 38460, upload-time = "2026-02-17T14:13:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/83/36/b921507d86a1870fc2187f152add01110569390e20432da8a05a5e05cea0/bitstruct-8.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b94cb01f327e8154c4300fc690a69fd1d856064cbcc5b0bf2e8c2f090e40a5c7", size = 82588, upload-time = "2026-02-17T14:13:51.438Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cf/c74a347fb44ba04526b70d2dd37e4715c3f66f8093db31bdbbdfc05bec06/bitstruct-8.22.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8b8f262edb37b1bbf37581f29f64e8e879b87101e07257e56ba47b5d797a3364", size = 85929, upload-time = "2026-02-17T14:13:52.417Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5e/3b5277a13e470c83e6903a4fe3527fd52c857f922c7d26f9466508096c4a/bitstruct-8.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f89b5a8057dff42383829f6fce9aadeba619c151685a90e0faf4ab53e940224b", size = 81811, upload-time = "2026-02-17T14:13:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d7/153322cad8fc7e03646248f3ab5bf9d17e5f206a307bb26c036d288f09d1/bitstruct-8.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:10321fc31368eeaecb78a34b5dd5071749243fc3ce426ff3bb2e4038bfd11454", size = 81721, upload-time = "2026-02-17T14:13:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/eb/c88bbedc0621ac40d523505706a3ba897668847b60473d830635787dc20e/bitstruct-8.22.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3f13a7df190dc54dddff97de7d2a8d1069e4022469e1d7aa66ae10f4eee04d3b", size = 77978, upload-time = "2026-02-17T14:13:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/03/4175dc4681d34ae09534c059bbcf170a8e45abceeb9f403f5af2905d27fa/bitstruct-8.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc084f5a7a8b258668ebde5e914a4c0290d496612161472eef079d469e49227e", size = 81102, upload-time = "2026-02-17T14:13:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/55/1e/e9bb9201232c62848db370e63171a212c5b2af15e6ede5c8f6b990052fd6/bitstruct-8.22.1-cp314-cp314-win32.whl", hash = "sha256:ae701de781999fa4eac2242bc8b4525c267ed17d8025291c57c6f1bf1a85b6c0", size = 35155, upload-time = "2026-02-17T14:13:57.676Z" }, + { url = "https://files.pythonhosted.org/packages/6a/aa/9b171198410cf09c38996b648ea6b9ccaf31ced8e48a489b8e0e16b8c739/bitstruct-8.22.1-cp314-cp314-win_amd64.whl", hash = "sha256:f5acccd329319e9c9f1e03a4b3fd6b91a6e58ab1810932b1c6dafa64891d569b", size = 37382, upload-time = "2026-02-17T14:13:58.69Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d9/c325024731326ac1fab4c17722961f8ada1391214b79885f8b0dce3b62d1/bitstruct-8.22.1-cp314-cp314-win_arm64.whl", hash = "sha256:7bca7c212a703f396f2558c76a5f07adb7b48bed3a128510eec381bc3ab9c7f2", size = 36161, upload-time = "2026-02-17T14:13:59.582Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2e/fa88136155b77f401b85f99caf1064138c4dd161cb0836c37960559ddc42/bitstruct-8.22.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c2e78952b2cc04d52f2acee6757198fee374f39e4f64d432382be54834d5044a", size = 38575, upload-time = "2026-02-17T14:14:00.448Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/b6e0cdfa06a47f203238b3921269f74cbb9503d963d14961ef578a12c5a9/bitstruct-8.22.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b092f12bad0e900cc2fea3c0884b8c280370c7c4e9adde3fef46ea4686fe8f4", size = 38834, upload-time = "2026-02-17T14:14:01.343Z" }, + { url = "https://files.pythonhosted.org/packages/9b/cc/06d8360fa724838def3902bbfd5da27c81a8c83c6f32848a90d4c943f9a9/bitstruct-8.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15e60bae7714d6312105fbc3e18185cf6638a4366f011f5d0306ac3a768e8406", size = 87733, upload-time = "2026-02-17T14:14:02.245Z" }, + { url = "https://files.pythonhosted.org/packages/94/3c/f4176c57c12f8af0eae25ca5d69213f62387fa98ab4a5ce0b48812f0ff2b/bitstruct-8.22.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5891d506486915120a073802bc8b391a1840327fc781bece88173fcef69300dd", size = 87975, upload-time = "2026-02-17T14:14:03.234Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9f/5853794e0c91e859b08cee513eb359b87b0d55b1f3c670d12b3ab4666d56/bitstruct-8.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68a7ed240b2448be339121a7b155c6a237460da236b6ba0c898861e156f7cd4b", size = 85772, upload-time = "2026-02-17T14:14:04.186Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a3/aaa3b705f65afa76399e7fa5296ffec496d44762c8b6a88f1cef373b38d6/bitstruct-8.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:21b94ba5210acada44d092170daf885777df2043c5c7b82a2b39e4a0b9a98349", size = 86676, upload-time = "2026-02-17T14:14:05.129Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/1a14810028fc02e92685d97b133928c0a323f8ba6cfae5ffe2f7d2ec57f3/bitstruct-8.22.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e7d38a321a00120109ce165a651afb3662e51d5a13134716548b6c0fc7b66987", size = 81658, upload-time = "2026-02-17T14:14:06.552Z" }, + { url = "https://files.pythonhosted.org/packages/84/3c/287eb7dda4be8a5cbddce9b7d12d58645ccb5c7669bf5555c653c749d022/bitstruct-8.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:505d18acd7cb2408bcde4009babb07d671a9b07ae4f3221b32bb332565452c6f", size = 85306, upload-time = "2026-02-17T14:14:07.574Z" }, + { url = "https://files.pythonhosted.org/packages/51/ef/5e5b26c6e85ce045902ef121e6fded15ec5486b6cb701b6e3ddf2d6b8949/bitstruct-8.22.1-cp314-cp314t-win32.whl", hash = "sha256:4ca7d883594451d1a5066e4a17ed3858ab7f8e26e419085b9c283e2d4c45eb25", size = 35604, upload-time = "2026-02-17T14:14:08.563Z" }, + { url = "https://files.pythonhosted.org/packages/26/71/74fbdb377e1dbff8945100e33e143acf8f2a9b859c4bee4e45759ed5da61/bitstruct-8.22.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a8227add6ff956e559d00442dc1ce23e31450d42eb5c15fa87e45b3a7f0fb79d", size = 37819, upload-time = "2026-02-17T14:14:10.415Z" }, + { url = "https://files.pythonhosted.org/packages/1e/fa/3b735cdb1975cf3584a65e501b2c1e5e0c578711e3ed35248196c324fc49/bitstruct-8.22.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ca5dbad59125547c14f29220ebe8d1cb46fcb3b50fbf16fb31f6ac190790b738", size = 36457, upload-time = "2026-02-17T14:14:11.331Z" }, +] + +[[package]] +name = "bleak" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/cb/20f3dd0498a820278c7078dce786676d18342721a3723591d5853323b363/bleak-2.0.0.tar.gz", hash = "sha256:a8043fc0f3af1a00a84963824195463ca39789ae4f6804d3d7b1423e6083254a", size = 119177, upload-time = "2025-11-22T18:21:52.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/52/43ac69d8a1e7e9aa20205ad0e106a08b3b4f8199def3f4e3e43c1439bc0b/bleak-2.0.0-py3-none-any.whl", hash = "sha256:a4ee68ab98b0d39eb3b2a937b2f95b93123258f301b0d0087edc4529809c77b1", size = 139953, upload-time = "2025-11-22T18:21:51.487Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, +] + +[[package]] +name = "customtkinter" +version = "5.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "darkdetect" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/48/c5a9d44188c44702e1e3db493c741e9c779596835a761b819fe15431d163/customtkinter-5.2.2.tar.gz", hash = "sha256:fd8db3bafa961c982ee6030dba80b4c2e25858630756b513986db19113d8d207", size = 261999, upload-time = "2024-01-10T02:24:36.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b1/b43b33001a77256b335511e75f257d001082350b8506c8807f30c98db052/customtkinter-5.2.2-py3-none-any.whl", hash = "sha256:14ad3e7cd3cb3b9eb642b9d4e8711ae80d3f79fb82545ad11258eeffb2e6b37c", size = 296062, upload-time = "2024-01-10T02:24:33.53Z" }, +] + +[[package]] +name = "darkdetect" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/77/7575be73bf12dee231d0c6e60ce7fb7a7be4fcd58823374fc59a6e48262e/darkdetect-0.8.0.tar.gz", hash = "sha256:b5428e1170263eb5dea44c25dc3895edd75e6f52300986353cd63533fe7df8b1", size = 7681, upload-time = "2022-12-16T14:14:42.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85", size = 8955, upload-time = "2022-12-16T14:14:40.92Z" }, +] + +[[package]] +name = "dbus-fast" +version = "5.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" }, + { url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "fit-tool" +version = "0.9.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitstruct" }, + { name = "openpyxl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/df/110dcfb7c8c985622f1b6c9c768a68eadf8b29974d36443422b79a177076/fit_tool-0.9.15.tar.gz", hash = "sha256:366ffe75ec3c3313a4164a3205b0eff8b7146f3d5c9c3f6fad8291c88b986cb1", size = 154570, upload-time = "2026-02-02T08:29:25.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/ca/20fe2dc451a3e22c483659a511a71a31c29a6569610a3e14c5a6cb712673/fit_tool-0.9.15-py3-none-any.whl", hash = "sha256:4d367e9fd146dc65288de2bfb9b63f54965485a7a3259fbaaadfefd1ba83e82e", size = 249339, upload-time = "2026-02-02T08:29:24.237Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "kayakfit" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "bleak" }, + { name = "customtkinter" }, + { name = "fit-tool" }, + { name = "keyring" }, + { name = "pyyaml" }, + { name = "requests" }, +] + +[package.dev-dependencies] +build = [ + { name = "pyinstaller" }, + { name = "pyinstaller-hooks-contrib" }, +] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "types-setuptools" }, +] + +[package.metadata] +requires-dist = [ + { name = "bleak", specifier = "==2.0.0" }, + { name = "customtkinter", specifier = "==5.2.2" }, + { name = "fit-tool", specifier = "==0.9.15" }, + { name = "keyring", specifier = "==25.6.0" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "requests", specifier = "==2.33.0" }, +] + +[package.metadata.requires-dev] +build = [ + { name = "pyinstaller", specifier = "==6.21.0" }, + { name = "pyinstaller-hooks-contrib", specifier = "==2026.6" }, +] +dev = [ + { name = "mypy", specifier = "==2.2.0" }, + { name = "pytest", specifier = ">=9.0.3,<10" }, + { name = "ruff", specifier = "==0.15.21" }, + { name = "types-pyyaml", specifier = "==6.0.12.20250915" }, + { name = "types-requests", specifier = "==2.32.4.20250913" }, + { name = "types-setuptools", specifier = "==80.9.0.20250822" }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mypy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/7e/be536678c6ae49ef058aba4b483d8c7bc104f471479016066f345bc1f5f8/mypy-2.2.0.tar.gz", hash = "sha256:2cdd99d48590dce6f6b7f1961eda75386364398fcdaad86923bc0f0231bf9baf", size = 3950939, upload-time = "2026-07-08T01:37:27.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/32/0aa8d8d197023ca6040f7b25a486cb47037b6350b0d3bae657c8f85fb43f/mypy-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1f6c3d76853071409ac58fc0aadfb276a22af5f190fdaa02152a858088a39ebd", size = 14926083, upload-time = "2026-07-08T01:36:02.365Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/35bbe0cb10e6699f90e988e537aaf4282a6c16e37f58848a242eb0a98bde/mypy-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bdaa177e80cc3292824d4ef3670b5b58771ee8d57c290e0c9c89e7968212332c", size = 13879985, upload-time = "2026-07-08T01:36:31.093Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0c/1597fbebd873e9b63452317740ae3dd32692cec5da180cc65acd96cd28cf/mypy-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80923e6d6e7878291f537ee11052f974954c20cb569798429a5dc265eb780b47", size = 14076883, upload-time = "2026-07-08T01:34:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/2ec021a83ec01b5d522639f78d8b36adade7fa4821db0f48fd6d82e861f3/mypy-2.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f24bd465a09077c8d64be8f19a6646db467a55490fd315fe7871afe6bb9645", size = 15103567, upload-time = "2026-07-08T01:36:47.717Z" }, + { url = "https://files.pythonhosted.org/packages/53/3a/8cb3529f6d6800c7d069935e5c83a05d80263847b8a947cf6b0b16a9e958/mypy-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db34595464869f474708e769413d1d739fc33a69850f253757b9a4cc20bc1fec", size = 15354641, upload-time = "2026-07-08T01:36:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/320bc9a9553f8a9db5e847ec5ded762ef7ed7403c76c4ba2e8181c80e2f0/mypy-2.2.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b48092132c7b0ef4322773fecae62fc5b0bc339be348badeec8af502122a4a51", size = 7694355, upload-time = "2026-07-08T01:37:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/90/05/bf3b349e2f885cd3aab488111bb9049439c28bc028dac5073350d3df8fbe/mypy-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:6fc0e98b95e31755ca06d89f75fafa7820fbb3ea2caace6d83cba17625cd0acb", size = 11329146, upload-time = "2026-07-08T01:35:38.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/a5/558b06e6cfe17ab88bb38f7b370b6bc68a74ba177c9e138db9748e422d2d/mypy-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:bc73a5b4d40e8a3e6b12ef82eb0c90430964e34016a36c2aff4e3bfe37ba41f0", size = 10316586, upload-time = "2026-07-08T01:36:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/0b/21/f0b96f19a9b8ba111a45ffbe9508e818b7f6990469b38f6888943f7bfd3a/mypy-2.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6257bd4b4c0ae2148548b869a1ff3758e38645b92c8fe65eca401866c3c551c1", size = 15922565, upload-time = "2026-07-08T01:35:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/f2/1dbcb20b0865d5e992541450a8c73f2fcc90f8bd7d8a4b81313e16934870/mypy-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dfa22b3ae862ac1ce76f5976ddd402651b5f090bcfd49c6d0484b8983a29eaf8", size = 14816515, upload-time = "2026-07-08T01:34:58.167Z" }, + { url = "https://files.pythonhosted.org/packages/84/a2/18cce9c7d5b4d14010d1f13836da11b234dda917b17ca8671fc32c136997/mypy-2.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a430bf26fe8cf372f3933fbd83e633d6561868803645a20e4e6d4523f52a3b", size = 15272246, upload-time = "2026-07-08T01:37:08.72Z" }, + { url = "https://files.pythonhosted.org/packages/ce/71/24d720c7924829bd675cbde2d0fa779f50abf676ca617f53d6a8bfef5fa7/mypy-2.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d58655a60e823b1a4c9ebcda072897fb0193c2f3e6f8e7c433e152aa4cb00233", size = 16226295, upload-time = "2026-07-08T01:34:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/b7/5e/785730990fc863ad8340b4ab44ac4ca23270aecff92c180ccdf27f9f5869/mypy-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d4452c955caf14e28bb046cbd0c3671272e6381630a8b81b0da9713558148890", size = 16493275, upload-time = "2026-07-08T01:35:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/33/55b1edf16f639f153972380d6977b81f65509c5b8f9c86b58b94b7990b03/mypy-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:68f5b7f7f755200f68c7181e3dfb28be9858162257690e539759c9f57721e388", size = 11749038, upload-time = "2026-07-08T01:35:50.071Z" }, + { url = "https://files.pythonhosted.org/packages/61/36/67424748a4e65e97f0e05bf00df379dfb6c2d817f82cc3a4ce5c96d99beb/mypy-2.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:78d201accfafce3801d978f2b8dbbd473a9ce364cc0a0dfd9192fe47d977e129", size = 10704254, upload-time = "2026-07-08T01:37:17.971Z" }, + { url = "https://files.pythonhosted.org/packages/28/cb/142c2097ca02c0d295b00625ff946808bdda65acda17d163c680d8a6a474/mypy-2.2.0-py3-none-any.whl", hash = "sha256:ecc138da861e932d1344214da4bae866b21900a9c2778824b51fe2fb47f5180e", size = 2726094, upload-time = "2026-07-08T01:34:00.075Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" }, + { url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/91/c76f3c5e8e80c7047e43c4c05b3e6fda9a7cefad5aae85487007674c966c/pyobjc_framework_corebluetooth-12.2.1.tar.gz", hash = "sha256:7dbb285295097205bebbcb11f55161e5faa02111108fb7b17536176e31971eb0", size = 37568, upload-time = "2026-06-19T16:20:12.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/ff/6f3b0bb3110ec82dbedaea47de151bd688980f5aadc634ef0cd236fdbd16/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2a2e6d56f51e4ca3e3b9766ef34150a9a7ce5f0cf4f9ee879ec10923af58e97e", size = 13223, upload-time = "2026-06-19T16:08:30.672Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/4e12660569219e4a68186ae9709b85278d3ebaf8d2f8e1c826a7337f4f7a/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50d7e4245dbdc8789dcc1f11fca2e633aa126a298b09db62f8216531fe107ee2", size = 13414, upload-time = "2026-06-19T16:08:31.679Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4c/976ae9bcce3615af806e3c314ea9caa3faacf11ec44f00b1a149559c6cb3/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c8d126c56b71c25218be186930a1b41739f83e931726a52e3298beeb170c5e5b", size = 13222, upload-time = "2026-06-19T16:08:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/99/be/44bb648a6b5c8aec79138bf562dab9eef414016ee31f37066bf81d809ae9/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:81023518feb75e9b2b676b28198955c51ae00548cf23c73c524c7101263b68db", size = 13424, upload-time = "2026-06-19T16:08:33.336Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/3f/561653aff3f19873457c95c053f0298da517be89fdfc0ec35115ed5b7030/pyobjc_framework_libdispatch-12.2.1.tar.gz", hash = "sha256:0d24eda41c6c258135077f60d410e704bc7b5a67adcb2ca463918896c7363795", size = 40336, upload-time = "2026-06-19T16:20:56.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/87/5b4a6c8580f2a486daf4b0d14a2356c47abfda401b329e71e46ac9b5460c/pyobjc_framework_libdispatch-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:999bad9a2c9198c837ba8f57a3ca9f05b4fc4bf7b69318baaa266dd2ab2fc8f7", size = 15699, upload-time = "2026-06-19T16:12:52.917Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/68cff50cb37a6ea311b7e805105ea13c33043762772714bc25d269c0730d/pyobjc_framework_libdispatch-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3fc93971f40d9757995c1e4b995a1614a468a5178be27e3d81e9bdc0b5e3cf75", size = 15981, upload-time = "2026-06-19T16:12:53.845Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5d/1f48e023555817f1271e86849ebd092743fc8bd292b6f82e87aba5df6122/pyobjc_framework_libdispatch-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:26a096c81c8cf272f4f1bb8f6c4b7565e005d273d218b53b83d925da5292633f", size = 15719, upload-time = "2026-06-19T16:12:54.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/44/b45c32851a3bcd367c62804c23aa55ea7918af6e16fddf1df23f5d7ca750/pyobjc_framework_libdispatch-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:82c6512fb4985f3bcd6b60b0cff79a4b483b44d1d2e5405010e34dd4b60aa01b", size = 16009, upload-time = "2026-06-19T16:12:55.513Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250913" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, +] + +[[package]] +name = "types-setuptools" +version = "80.9.0.20250822" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/95/91cfdf941a1ba791708ab3477fc4e46793c8fe9117fc3e0a8c5ac5d7a09c/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a", size = 109015, upload-time = "2025-09-20T07:09:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/7460655628d0f340a93524f5236bb9f8514eb0e1d334b38cba8a89f6c1a6/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c", size = 115931, upload-time = "2025-09-20T07:09:51.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/70/e1248dea2ab881eb76b61ff1ad6cb9c07ac005faf99349e4af0b29bc3f1b/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102", size = 109561, upload-time = "2025-09-20T07:09:52.733Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/3d/421d04a20037370baf13de929bc1dc5438b306a76fe17275ec5d893aae6c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214", size = 92264, upload-time = "2025-09-20T07:09:53.563Z" }, + { url = "https://files.pythonhosted.org/packages/07/c7/43601ab82fe42bcff430b8466d84d92b31be06cc45c7fd64e9aac40f7851/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2", size = 97517, upload-time = "2025-09-20T07:09:54.411Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/e3303f6a25a2d98e424b06580fc85bbfd068f383424c67fa47cb1b357a46/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a", size = 94122, upload-time = "2025-09-20T07:09:55.187Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/32/cb447ca7730a1e05730272309b074da6a04af29a8c0f5121014db8a2fc02/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f", size = 185249, upload-time = "2025-09-20T07:09:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fa/f465d5d44dda166bf7ec64b7a950f57eca61f165bfe18345e9a5ea542def/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851", size = 193739, upload-time = "2025-09-20T07:09:59.893Z" }, + { url = "https://files.pythonhosted.org/packages/78/08/51c53ac3c704cd92da5ed7e7b9b57159052f6e46744e4f7e447ed708aa22/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892", size = 194836, upload-time = "2025-09-20T07:10:00.87Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/31/5785cd1ec54dc0f0e6f3e6a466d07a62b8014a6e2b782e80444ef87e83ab/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106", size = 134252, upload-time = "2025-09-20T07:10:12.935Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/68d91068048410f49794c0b19c45759c63ca559607068cfe5affba2f211b/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472", size = 145509, upload-time = "2025-09-20T07:10:13.797Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a4/898951d5bfc474aa9c7d133fe30870f0f2184f4ba3027eafb779d30eb7bc/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22", size = 141334, upload-time = "2025-09-20T07:10:14.637Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/79/4627afae6b389ddd1e5f1d691663c6b14d6c8f98959082aed1217cc57ef9/winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6", size = 39521, upload-time = "2025-09-20T07:11:17.992Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/c6aea91908ee7279ed51d12157bc8aeecb8850af2441073c3c91b261ad31/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf", size = 41121, upload-time = "2025-09-20T07:11:18.747Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/652f14e3c501452ad8e0723518d9bbd729219b47f4a4dbe2966c2f82dca8/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312", size = 38114, upload-time = "2025-09-20T07:11:19.599Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/47/b3301d964422d4611c181348149a7c5956a2a76e6339de451a000d4ae8e7/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab", size = 62211, upload-time = "2025-09-20T07:11:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/5f2c940ff606297129e93ebd6030c813e6a43a786de7fc33ccb268e0b06b/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4", size = 70399, upload-time = "2025-09-20T07:11:53.254Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2c8eb89062c71d4be73d618457ed68e7e2ba29a660ac26349d44fc121cbf/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01", size = 61392, upload-time = "2025-09-20T07:11:53.993Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/70/2869ea2112c565caace73c9301afd1d7afcc49bdd37fac058f0178ba95d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee", size = 131701, upload-time = "2025-09-20T07:17:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/aae50b1d0e37b5a61055759aedd42c6c99d7c17ab8c3e568ab33c0288938/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0", size = 135566, upload-time = "2025-09-20T07:17:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c3/6d3ce7a58e6c828e0795c9db8790d0593dd7fdf296e513c999150deb98d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987", size = 134393, upload-time = "2025-09-20T07:17:18.802Z" }, +] diff --git a/workout_worker.py b/workout_worker.py new file mode 100644 index 0000000..2cf502f --- /dev/null +++ b/workout_worker.py @@ -0,0 +1,147 @@ +"""Workout worker: the live data-collection loop. + +Runs the recording side of a session as a worker coordinated by the main GUI: +loads config (YAML), discovers and connects the ergometer (and optional HRM) +over BLE, streams real-time samples into a :class:`app.workout_session. +WorkoutSession` that writes the CSV, and shuts down cleanly on request. The +companion :mod:`export_worker` handles the CSV-to-FIT / upload side. +""" + +import asyncio +import contextlib +import threading +from collections.abc import Callable +from typing import Any + +from app.events import StatusPayload +from app.program import Program +from app.worker_result import WorkerResult +from app.workout_session import WorkoutSession +from gui.config_manager import ConfigError, ConfigManager + + +def _log_wrapper(log_callback: Callable[[str], None] | None, msg: str) -> None: + """Helper to log messages either to callback or print.""" + if log_callback: + log_callback(msg + "\n") + else: + print(msg) + + +async def run_workout_worker( + boat_weight: int | None = None, + person_weight: int | None = None, + ergometer_mac: str | None = None, + log_callback: Callable[[str], None] | None = None, + stop_event: threading.Event | None = None, + data_callback: Callable[[dict[str, Any]], None] | None = None, + status_callback: Callable[[StatusPayload], None] | None = None, + prompt_callback: Callable[[str, str], bool] | None = None, + program: Program | None = None, + advance_event: threading.Event | None = None, +) -> WorkerResult: + """Run workout worker with optional overrides. + + Args: + boat_weight: Optional boat weight override (kg) + person_weight: Optional person weight override (kg) + ergometer_mac: Optional ergometer MAC address override + log_callback: Optional callback function for logging + stop_event: Optional threading.Event to signal stop request + data_callback: Optional callback receiving each recorded sensor sample + status_callback: Optional callback receiving connection/session status events + prompt_callback: Optional blocking yes/no prompt ``(title, message) -> bool`` + program: Optional structured training Program to drive the session + advance_event: Optional threading.Event to manually advance program steps + """ + def log(msg: str) -> None: + _log_wrapper(log_callback, msg) + + # Load and validate config (single source of truth). + try: + config = ConfigManager().load_runtime_config() + except ConfigError as e: + log(f"ERROR: {e}") + return WorkerResult( + outcome="failed", stage="configuration", message=str(e), retryable=True + ) + + # Override ergometer MAC if provided + if ergometer_mac: + config['ergometer_mac'] = ergometer_mac + log(f"Using ergometer MAC from arguments: {ergometer_mac}") + + # Display configuration only if values differ from config + overrides = [] + if boat_weight is not None and boat_weight != config['boat_weight_default']: + overrides.append( + f" Boat weight: {boat_weight}kg " + f"(overriding config: {config['boat_weight_default']}kg)" + ) + if person_weight is not None and person_weight != config['person_weight_default']: + overrides.append( + f" Person weight: {person_weight}kg " + f"(overriding config: {config['person_weight_default']}kg)" + ) + + if overrides: + log("Starting workout with overrides:") + for override in overrides: + log(override) + + # Create and run session with overrides, with 6-hour timeout + session = WorkoutSession( + config, + boat_weight=boat_weight, + person_weight=person_weight, + log_callback=log_callback, + data_callback=data_callback, + status_callback=status_callback, + prompt_callback=prompt_callback, + program=program, + advance_event=advance_event, + ) + + # If stop_event provided, create a task to monitor it + stop_task = None + if stop_event: + async def check_stop_flag() -> None: + while not stop_event.is_set(): + await asyncio.sleep(0.1) + log("\nStop signal received from GUI...") + session.stop_event.set() + stop_task = asyncio.create_task(check_stop_flag()) + + try: + return await asyncio.wait_for(session.run(), timeout=6 * 3600) + except TimeoutError: + # wait_for cancelled session.run(); its finally block has already + # disconnected the devices, closed the CSV and emitted the summary. + log("\nWorkout session exceeded maximum duration of 6 hours. Stopped.") + return WorkerResult( + outcome="partial" if session.data_point_count > 0 else "failed", + stage="timeout", + message="Workout exceeded the six-hour safety limit", + csv_path=str(session.csv_path) if session.csv_path else None, + durable_rows=( + session.csv_writer.records_written if session.csv_writer else 0 + ), + retryable=True, + ) + except Exception as exc: + log(f"\nWorkout worker failed: {exc}") + return WorkerResult( + outcome="partial" if session.data_point_count > 0 else "failed", + stage="worker", + message=str(exc), + csv_path=str(session.csv_path) if session.csv_path else None, + durable_rows=( + session.csv_writer.records_written if session.csv_writer else 0 + ), + retryable=True, + ) + finally: + if stop_task: + stop_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await stop_task