diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d85d9ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main, development] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements*.txt + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: ruff check . + - run: python -m pytest --cov=. --cov-report=term-missing diff --git a/.gitignore b/.gitignore index f2cb5de..14dc5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ config.json *.db *.db-wal *.db-shm +.coverage +htmlcov/ +coverage.xml + +data/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..38a6629 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,25 @@ +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS + when: never + - if: $CI_COMMIT_BRANCH + +test: + image: python:3.12 + variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + cache: + key: + files: [requirements.txt, requirements-dev.txt] + paths: [.cache/pip] + script: + - pip install -r requirements.txt -r requirements-dev.txt + - ruff check . + - python -m pytest --cov=. --cov-report=term-missing --cov-report=xml + coverage: '/^TOTAL.+?(\d+(?:\.\d+)?%)$/' + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ecbe8ae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# AGENTS.md + +## Docs +Keep all docs (ROADMAP.md, CHANGELOG.md `[Unreleased]`, README.md, docs/) in sync with the code and with any plans discussed, in the same change. Released CHANGELOG sections are immutable. + +## Git +- Never commit. The maintainer signs commits, and agent commits come out unsigned. Hand over the commit message and list the files to add. Messages follow [Conventional Commits](https://www.conventionalcommits.org/): `type(scope): summary`, e.g. `fix(ui): show transition hint only during belt sequences (ROADMAP 3.18)`, ending with a `Co-Authored-By:` trailer naming the agent. +- Don't stage unless asked in that turn, and then `git add` specific paths only (never `docs/` plan files). The maintainer reviews `git diff` first. +- Run `git status`/`git log` before any claim about commit state; the maintainer commits between turns, so earlier snapshots go stale. +- Feature branches merge into `development`, not `main`. Open PRs with `--base development` and scope diffs/logs against `origin/development`. + +## Platforms +The maintainer runs macOS (primary) and Fedora Linux, never Windows. Suggest manual checks on macOS first, Fedora when Linux behavior differs. Windows-only code (`start_app.bat`, `WSAE*` errnos, Windows socket semantics) can't be verified by the maintainer; flag it rather than asking for a test. + +## Design target +The primary client is a laptop browser at a desk above the treadmill. Design for desktop width and mouse/keyboard first; keep the phone layout working, but it doesn't drive layout or feature decisions. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3ea6d..65f3ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to WalkingDad will be documented in this file. +## [1.9.0] (2026-10-08) + +### Added + +- **Add to Home Screen opens WalkingDad full-screen** (ROADMAP 3.19), like an app, without the browser's address and tool bars. +- **Log to Apple Health from the iPhone or iPad running WalkingDad.** A phone can't scan its own screen, so on iPhone/iPad the per-session QR code is now a **Log to Apple Health** button, and the setup QR on the Settings page is an **Install Shortcut** button. Computers still show the QR codes, since a Mac can't write to Apple Health. +- **The Log to Apple Health prompt clears itself once the workout is logged**, on every open WalkingDad page. After the Shortcut finishes, the phone opens WalkingDad in Safari, which marks that session as logged. Dismissing the prompt is now recorded separately from logging. The installed Shortcut doesn't need to change. +- **See which sessions were logged to Apple Health, and log missed ones later.** With Apple Health export on, each session in Recent Sessions shows a filled heart once logged and an outline heart if not. **Edit** adds a **Log** button to each unlogged session. +- **Apple Health export logs steps.** WalkingDad now sends the session's step count to the Shortcut, which logs it as a Steps sample alongside the workout. Reinstall the Shortcut from Settings to pick this up; the old one keeps working without steps. +- **Delete individual sessions.** **Edit** also adds a delete button to each session in Recent Sessions, handy for removing test walks without clearing everything. +- **The screen stays on during an active session** (ROADMAP 3.12), and returns to normal on Pause or End. Browsers only allow this on `localhost` or HTTPS, so it works on the computer running WalkingDad but not on a phone connecting over your network. + +### Changed + +- **WalkingDad now has a real phone layout** (ROADMAP 3.19). Phones previously showed a shrunken desktop page. Now the speed reading fills the screen, the preset and Pause/End buttons sit full-width at the bottom within thumb reach, every button is large enough to tap while walking, the header controls fold into one menu button, and Recent Sessions shows as cards instead of a wide table. The desktop layout is unchanged. +- **Buttons show a pressed state when clicked or tapped**, instead of fading to transparent while held. +- **Everything WalkingDad writes now lives in a `data/` folder** instead of next to the code: settings (`data/config.json`), the session database, crash-recovery state, and the old JSON-migration backups (`data/backups/`). Existing files move there automatically the first time you start this version; nothing is overwritten. To edit settings by hand, use `data/config.json`. +- **`/reconnect` is now POST-only**, like the belt-control routes. The Connect and Try Again buttons on the connecting screen look and work the same. + +### Fixed + +- **Other websites can no longer control the treadmill** (ROADMAP 2.2). A page open in your browser could previously submit Start, speed, Clear History, Bluetooth reconnect, or Shutdown requests to WalkingDad behind your back. Requests that come from another website are now refused. Using WalkingDad from any device on your network works exactly as before. +- **Pause and speed buttons no longer error out when Bluetooth isn't running.** If the Bluetooth connection had already gone away, pressing Pause or a speed button showed a server error page. Pause now still pauses the session, and the speed buttons do nothing. +- **A corrupted crash-recovery file no longer stops the app from starting.** If `session_state.json` contained unreadable (non-UTF-8) bytes, startup crashed. Startup now ignores the file, the same as an unparseable one. +- **Speed buttons no longer flash a "Getting the belt moving…" message** (ROADMAP 3.18). Presets and the +/- buttons briefly showed it and pushed the buttons down, and Start, Pause, Resume and End flashed the wrong message ("Stopping belt…", "Pausing belt…"). The message now appears only while the belt is actually starting, pausing or stopping. +- **A quick double-tap can no longer send a button press twice.** Buttons could re-enable mid-request on a live stats update; they now stay disabled from the moment you press one until the next screen loads. + +### Internal + +- **Tests now cover nearly all of the Python code** (ROADMAP 2.5): routes, Bluetooth sequences against a fake treadmill, status-packet math, persistence, and `run.py`'s launcher. The suite runs in about a second. +- **Continuous integration** (ROADMAP 2.7): `ruff` lint and the test suite run on pull/merge requests and on pushes to `main`/`development` via GitHub Actions (Python 3.10 and 3.13) and GitLab CI (Python 3.12, with coverage shown in merge requests). `ruff` is pinned in `requirements-dev.txt`, and lint fixes in `app.py` (imports sorted, unused `global` declarations removed) have no behavior change. +- **Importing `app.py` with `WALKINGDAD_NO_STARTUP=1` no longer installs the Ctrl+C/SIGTERM handlers or the `atexit` hook**, so pressing Ctrl+C during a test run interrupts pytest instead of killing the process two seconds later. Running the app normally is unchanged. +- **pytest, ruff, and coverage settings merged into `pyproject.toml`**, replacing `pytest.ini`, `ruff.toml`, and `.coveragerc`. `requirements-dev.txt` adds `coverage[toml]` so coverage reads it on Python 3.10. +- **`run.py`'s launcher logic moved into a `main()` function** so it can be tested. `python run.py` behaves the same. +- **`AGENTS.md` added for AI coding agents** (`CLAUDE.md` is a symlink to it): keep docs in sync with code, the git workflow (PRs target `development`, the maintainer commits), the desktop-first design target, and the supported platforms. + +--- + ## [1.8.0] (2026-10-05) ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index b0acaa8..97abc9f 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ The official WalkingPad experience is a bloated mobile app that wants your email - **Smart pause & resume**: Auto-detects when you step off; remembers your speed; configurable grace period prevents re-triggering on restart; a session left paused too long (default 30 min) is ended and saved automatically - **Speed presets**: Slow (speed floor), Moderate, and Max buttons, plus incremental increase/decrease steppers - **Console-style interface**: Active, Paused, and Start screens read like the WalkingPad's own onboard display, with one large tabular-digit reading up top and secondary stats in a compact readout strip below -- **Session history**: Sessions saved to a local SQLite database (`walkingdad.db`) with full stats, pauses, and per-second speed/distance/steps samples; last 10 shown on the start screen. Includes CSV export and history clearing. An existing `session_history.json` from an older version is imported automatically on first launch (the original is kept as `session_history.json.bak-`). +- **Session history**: Sessions saved to a local SQLite database (`data/walkingdad.db`) with full stats, pauses, and per-second speed/distance/steps samples; last 10 shown on the start screen. Includes CSV export, history clearing, and an **Edit** mode for deleting individual sessions. An existing `session_history.json` from an older version is imported automatically on first launch (the original is kept as `data/backups/session_history.json.bak-`). - **Crash recovery**: If the server crashes or restarts mid-session, your stats aren't lost. The start screen offers to restore the interrupted session (paused, ready to resume) or discard it. -- **Settings page**: Gear icon in the header lets you change any setting (device name, speed limits, port, and more) from the browser without editing files +- **Settings page**: Gear icon in the header lets you change settings (device name, speed limits, port, and more) from the browser without editing files - **Dark mode**: Three-state toggle (Light → Dark → System) with localStorage persistence - **Color themes**: Independent of the Light/Dark/System toggle, via the palette icon in the header. Each theme tints the whole surface, not just buttons, and follows into the browser's own chrome (tab color, etc). - Standard palette: Slate is the default; the rest run in spectral order: @@ -39,7 +39,7 @@ The official WalkingPad experience is a bloated mobile app that wants your email - ![Tide](https://img.shields.io/badge/Tide-11818c) summer, turquoise/sand, Pacifico headings, crabs scuttling along the bottom - ![Harvest](https://img.shields.io/badge/Harvest-c2410c) mode-aware: cozy autumn (falling leaves) in Light, spooky Halloween (glowing eyes) in Dark - ![Frost](https://img.shields.io/badge/Frost-b91c1c) winter, falling snow -- **Apple Health export**: Scan a QR code after a session ends to log it as a Workout on your iPhone, no manual re-entry. Runs entirely through a Shortcut on your own device; no Apple Developer account, no cloud service. See [Apple Health Export](#apple-health-export) below. +- **Apple Health export**: After a session ends, scan a QR code (or, on an iPhone/iPad running WalkingDad, tap a button) to log it as a Workout on your iPhone, no manual re-entry. Runs entirely through a Shortcut on your own device; no Apple Developer account, no cloud service. See [Apple Health Export](#apple-health-export) below. - **Cross-platform BLE**: Tested on Windows, macOS, and Linux with retry logic and event loop cleanup - **Graceful shutdown**: Stops the belt, switches to standby, and disconnects BLE whether you click Close in the UI, press Ctrl+C, or kill the process. Web UI shows "Server is shutting down" notification so you know what happened. Includes an `atexit` safety net as a last resort. - **No account. No cloud. No phone required.** @@ -79,11 +79,11 @@ The app opens your browser automatically at `http://127.0.0.1:5001`. A console w **Windows shortcut:** Double-click `start_app.bat` instead of running the commands manually. -**Running tests:** `pip install -r requirements-dev.txt`, then `python -m pytest`. No treadmill needed. Tests that import `app.py` run with `WALKINGDAD_NO_STARTUP=1` (set in `tests/conftest.py`), which skips its import-time startup: opening the real database, migrating `session_history.json`, and the Bluetooth scan. +**Running tests:** `pip install -r requirements-dev.txt`, then `python -m pytest` (add `--cov=.` for a coverage report) and `ruff check .`. No treadmill needed. Tests that import `app.py` run with `WALKINGDAD_NO_STARTUP=1` (set in `tests/conftest.py`), which skips its import-time startup: opening the real database, migrating `session_history.json`, the Bluetooth scan, and installing the Ctrl+C/exit handlers. CI runs the same lint and tests on pull/merge requests and on pushes to `main`/`development`, via GitHub Actions (`.github/workflows/ci.yml`) and GitLab CI (`.gitlab-ci.yml`). ## Usage -1. Power on your WalkingPad. The app connects automatically on startup (up to 3 retries with exponential backoff). +1. Power on your WalkingPad. The app connects automatically on startup (up to 3 attempts with exponential backoff). 2. Click **Start** to begin a session. Stats update in real time. 3. Adjust speed with the control buttons, or click **Pause** to stop the belt. 4. If you step off the pad, the app auto-pauses. Click **Resume** to pick back up. @@ -93,28 +93,33 @@ The app opens your browser automatically at `http://127.0.0.1:5001`. A console w Logs a completed session to Apple Health as a Workout, without typing anything in by hand. This only works on an iPhone (or iPad/Apple Watch). The Health app doesn't exist on macOS, so the Mac running WalkingDad can hand off the data but can't write it itself. No Apple Developer account is needed; this is a personal Shortcut on your own device, never distributed through the App Store. -**Off by default.** Turn it on first: Settings page (gear icon) → **Apple Health** → the **Enable Apple Health export** toggle. The rest of the section (Shortcut Name field, setup QR) only appears once it's on. +**Off by default.** Turn it on first: Settings page (gear icon) → **Apple Health** → the **Enable Apple Health export** toggle. The rest of the section (Shortcut Name field, setup QR or, on an iPhone/iPad, an **Install Shortcut** button) only appears once it's on. **One-time setup:** -1. With the toggle on, scan the QR code shown there with your iPhone's Camera app. It opens Apple's own "Get Shortcut" page for a Shortcut that reads the workout data WalkingDad hands it and logs it via the built-in **Log Workout** action. +1. With the toggle on, scan the QR code shown there with your iPhone's Camera app, or tap **Install Shortcut** if you're on the iPhone/iPad itself. Either opens Apple's own "Get Shortcut" page for a Shortcut that reads the workout data WalkingDad hands it and logs it via the built-in **Log Workout** action. 2. Tap **Add Shortcut**. That's it. This only needs to happen once per phone. -If you'd rather build the Shortcut by hand (e.g. you're maintaining a fork and want your own copy rather than relying on a link tied to someone else's iCloud account), it's three actions: +If you'd rather build the Shortcut by hand (e.g. you're maintaining a fork and want your own copy rather than relying on a link tied to someone else's iCloud account), it's four steps: 1. `Get Dictionary from Input` (reads the JSON handed to the Shortcut). 2. `Log Workout`, with **Type** set to **Walking**, and **Date** / **Duration** / **Calories** / **Distance** each bound via Magic Variable to the matching key from the dictionary above (`start_time`+`date`, `duration_seconds`, `calories`, `distance_km` or `distance_mi`). -3. Name the Shortcut to match the **Shortcut Name** setting on the Settings page (default `Log WalkingDad Workout`) exactly. This is how WalkingDad's QR code knows which Shortcut to run. +3. `Log Health Sample`, with **Type** set to **Steps**, **Value** bound to `steps`, and **Date** to the same start date. The Value field only appears once Shortcuts is allowed to write Steps to Health. +4. Name the Shortcut to match the **Shortcut Name** setting on the Settings page (default `Log WalkingDad Workout`) exactly. This is how WalkingDad's QR code knows which Shortcut to run. Then Share → Copy iCloud Link in the Shortcuts app, and swap `_APPLE_HEALTH_SHORTCUT_ICLOUD_LINK` in `app.py` for your own link. -**Every session after that:** when a session ends, the start screen shows a **Log to Apple Health** prompt. Tap it, scan the QR with your iPhone, done. The prompt sticks around (across reloads, navigating elsewhere, closing the browser) until you either scan it or tap **Dismiss**. It isn't a one-shot toast you can miss. +**Every session after that:** when a session ends, the start screen shows a **Log to Apple Health** prompt. On a computer, tap it and scan the QR with your iPhone. On an iPhone/iPad, it's a button that runs the Shortcut directly. Once the Shortcut finishes, the phone switches back to WalkingDad in Safari and the prompt clears on every open WalkingDad page, including the computer's. Until then it sticks around (across reloads, navigating elsewhere, closing the browser), or until you tap **Dismiss**. It isn't a one-shot toast you can miss. + +**Missed one?** With export on, each session in **Recent Sessions** shows a filled heart once it's been logged and an outline heart if it hasn't (including dismissed ones). Tap **Edit** to get a **Log** button on each unlogged session, which works the same as the prompt (QR on a computer, button on iPhone/iPad), plus a delete button on every session. Only the sessions shown there can be logged this way; raise `history_display_limit` to reach older ones. + +The phone reaches WalkingDad at the computer's network address, so this needs WalkingDad listening on the network (`host` `0.0.0.0`, the default), even if the computer itself uses `localhost`. If the phone can't reach it, the workout is still logged; only the prompt stays until you **Dismiss** it. **Known issue:** older reports describe a Shortcuts bug where the `Log Workout` action's Duration field doesn't bind correctly to a variable. It bound correctly (raw seconds, no conversion needed) in hands-on testing while building this feature, but if your logged workouts ever show the wrong duration, check the Shortcut's Duration field is still wired to the dictionary value rather than a hardcoded default before assuming it's a WalkingDad-side bug. ## Configuration -All settings can be changed from the **Settings page** (gear icon in the header), or by editing `config.json` directly. Copy `config.json.example` to `config.json` to get started; running without the file uses the built-in defaults shown below. +Every setting except `database_path` can be changed from the **Settings page** (gear icon in the header); all of them can be set by editing `data/config.json` directly. Copy `config.json.example` to `data/config.json` to get started; running without the file uses the built-in defaults shown below. Everything WalkingDad writes (settings, database, crash-recovery state, backups) lives in `data/`; files that older versions kept in the app folder are moved there automatically on first start. | Key | Default | Description | |---|---|---| @@ -127,11 +132,12 @@ All settings can be changed from the **Settings page** (gear icon in the header) | `resume_grace_period_seconds` | `7` | Seconds before auto-pause can trigger after start/resume (minimum 3) | | `history_display_limit` | `10` | Sessions shown on the start screen | | `stale_pause_timeout_minutes` | `30` | A session paused this long is ended and saved automatically; `0` disables | -| `host` | `"0.0.0.0"` | Network interface to bind | +| `host` | `"0.0.0.0"` | Network interface to bind. Any device that can reach it can control the treadmill (other websites can't); use `"127.0.0.1"` to allow only this computer | | `port` | `5001` | Server port | | `waitress_threads` | `16` | Server worker thread count (4-128) | +| `apple_health_export_enabled` | `false` | Shows the Log to Apple Health prompt after each session; see [Apple Health Export](#apple-health-export) | | `apple_health_shortcut_name` | `"Log WalkingDad Workout"` | Must match the installed Shortcut's name exactly; see [Apple Health Export](#apple-health-export) | -| `database_path` | `"walkingdad.db"` | SQLite database file (relative to the app directory) | +| `database_path` | `"walkingdad.db"` | SQLite database file (relative to `data/`) | Changes to most settings take effect immediately via the Settings page. `host`, `port`, `waitress_threads`, and `database_path` require restarting the app. diff --git a/ROADMAP.md b/ROADMAP.md index b757d71..db2a9e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,7 @@ Organized by theme (Area), not build order. Work has never moved through these t ### ✅ 1.1 macOS / Cross-Platform BLE Reliability Fixes **Status:** ✅ Complete +**Changelog:** `[1.0.0]` **Priority:** High **Files Modified:** `app.py` @@ -29,6 +30,7 @@ A comprehensive set of reliability improvements for Bluetooth Low Energy communi ### ✅ 1.2 Graceful Shutdown **Status:** ✅ Complete +**Changelog:** `[1.1.0]` **Priority:** High **Files Modified:** `app.py`, `run.py`, `templates/*.html` @@ -57,6 +59,7 @@ Additional fixes: ### ✅ 1.3 Session State Persistence **Status:** ✅ Complete +**Changelog:** `[1.5.0]` **Priority:** High **Files Modified:** `app.py`, `templates/start_session.html`, `.gitignore` @@ -77,6 +80,7 @@ Cumulative session stats now survive a server crash or restart. In-progress sess ### ✅ 1.4 Automatic Reconnect on Disconnection **Status:** ✅ Complete +**Changelog:** `[1.8.0]` **Priority:** High **Files Modified:** `app.py` @@ -96,6 +100,7 @@ Not configurable: retry counts and delays are module constants in `app.py`. ### ✅ 1.5 Auto-End Stale Paused Session **Status:** ✅ Complete +**Changelog:** `[1.8.0]` **Priority:** Medium **Files Modified:** `app.py`, `config.py`, `config.json.example`, `templates/paused_session.html`, `templates/settings.html`, `README.md`, `tests/test_stale_pause.py` @@ -111,6 +116,7 @@ A session left paused (manual, auto, Bluetooth drop, or restored after a crash) ### ✅ 1.6 Port-in-Use Check on Startup **Status:** ✅ Complete +**Changelog:** `[1.8.0]` **Priority:** Medium **Files Modified:** `run.py`, `start_app.bat`, `README.md`, `tests/test_port_check.py` @@ -129,6 +135,7 @@ A session left paused (manual, auto, Bluetooth drop, or restored after a crash) ### ✅ 2.1 External Configuration File **Status:** ✅ Complete +**Changelog:** `[1.4.0]` **Priority:** High **Files Modified:** `config.py`, `config.json.example`, `app.py`, `run.py`, `.gitignore`, `README.md`, `templates/base.html`, `templates/settings.html` @@ -136,15 +143,16 @@ All user-tunable settings are now loaded from an optional `config.json` file, wi --- -### 2.2 Route Security -- **Status:** Planned -- **Priority:** High -- **Problem:** Routes like `/start`, `/pause`, `/increase_speed` have no authentication or CSRF protection. Since the server binds to `0.0.0.0`, any device on the local network could control the treadmill. -- **Solution:** Add a simple secret token mechanism: - - Generate a random token on startup (stored in config) - - Require `?token=...` query parameter or `X-Auth-Token` header on all action routes - - Display the token in the UI and use it automatically for frontend requests -- **Alternative:** Restrict server to `127.0.0.1` only (breaks network access but is simpler). +### ✅ 2.2 Route Security +**Status:** ✅ Complete +**Changelog:** `[1.9.0]` +**Priority:** High +**Files Modified:** `app.py`, `templates/connecting.html`, `tests/test_csrf.py`, `tests/test_routes.py` + +Routes like `/start`, `/pause`, `/increase_speed` had no CSRF protection, so any website open in a browser on the network could control the treadmill. A `before_request` guard (`_block_cross_site_posts()`) now rejects any non-GET request whose `Origin` isn't the app's own origin (scheme, host, and port), or whose `Sec-Fetch-Site` is `cross-site` or `same-site` (catches it when an extension or proxy strips `Origin`). Requests without either header (curl, `run.py`'s `/shutdown`) pass. `/reconnect` became POST-only (the Connect / Try Again links on the connecting screen are now form buttons), so every state-changing route goes through the guard. + +- **Not done, deliberately:** a per-device token. The app is LAN-only and devices on the LAN are trusted by design; the treadmill is only reachable over Bluetooth from home, and anyone at home can press its buttons anyway. A token would add a sign-in step for every phone, break scripts, and add a credential to manage, guarding against a threat that doesn't apply. Revisit only if the app is ever exposed beyond the LAN (tunnel, Tailscale). +- **Known limit:** a deliberate DNS-rebinding attack makes `Origin` match the app's origin and gets through; that takes an attack targeted at this app specifically, so it's accepted. The fix would be a `Host` allowlist (IP literals, `localhost`, the machine's own name), at the cost of breaking access through custom DNS names (`.lan`, Tailscale). --- @@ -166,15 +174,26 @@ All user-tunable settings are now loaded from an optional `config.json` file, wi --- -### 2.5 Unit Tests -- **Status:** In Progress -- **Priority:** Medium -- **Problem:** Most of `app.py` has no automated tests, making refactoring risky. Covered so far: `storage.py`, the JSON migration, `units.py`, `samples.py` (2.9), and in `app.py` the stale-pause check (1.5) plus `process_status_packet()`'s active-time accrual and auto-pause detection, and `run.py`'s port-in-use check (1.6). -- **Solution:** Extend the suite to `app.py`'s logic: - - `format_seconds_to_hms()` - time formatting edge cases - - `kcal_estimate()` - calorie calculation - - `process_status_packet()` - distance/steps accumulation and device counter resets, speed history management (mock BLE data) -- **Implementation:** Tests import `app.py` directly; `tests/conftest.py` sets `WALKINGDAD_NO_STARTUP=1` so the import skips opening `walkingdad.db`, the JSON migration, the orphan sweep, the BLE thread, and the SSE broadcaster. +### ✅ 2.5 Unit Tests +**Status:** ✅ Complete +**Changelog:** `[1.8.0]`, `[1.9.0]` +**Priority:** Medium +**Files Modified:** `app.py`, `run.py`, `requirements-dev.txt`, `pytest.ini`, `ruff.toml`, `.coveragerc`, `.gitignore`, `README.md`, `tests/` + +A `pytest` suite covering every module without a treadmill. How to run it: the README's **Running tests** line. + +| Area | Coverage | +|---|---| +| **Pure helpers** | Time formatting, calorie estimate, status-field extraction, settings casts and clamps, Apple Health shortcut URLs, stats payload and SSE framing | +| **Status packets** | Distance/step accumulation across device counter resets, calories, speed history filtering and cap, resume speed chosen on auto-pause, liveness stamping (plus the existing active-time and auto-pause tests) | +| **Routes** | Every Flask route through the test client: start/pause/resume/end, speed controls and clamping, restore/discard, CSV export, settings save, `/stats`, `/stats_stream`, `/shutdown` | +| **BLE and async** | A fake controller drives the wake/start/stop sequences, light wake, lock timeouts, the stats monitor, the idle watchdog, disconnect handling, auto-reconnect backoff, scanning, connecting, the BLE thread lifecycle, and signal/HTTP shutdown | +| **Persistence** | Session-state file save/load/clear (including corrupt files), `config.json` writes, and every database helper against a temp SQLite DB, including "log and continue" behavior when storage fails | +| **`run.py`** | Port check, browser launch, the HTTP shutdown call, and `main()`'s Waitress subprocess and Ctrl+C handling | + +**Implementation:** `tests/conftest.py` sets `WALKINGDAD_NO_STARTUP=1` before importing `app.py`, so the import skips opening `walkingdad.db`, the JSON migration, the orphan sweep, the BLE thread, the SSE broadcaster, and installing the signal/`atexit` handlers. Its `app_state` fixture resets every module global, points the state file, `config.json`, and the database at `tmp_path`, and records BLE coroutines instead of scheduling them, so tests can run a captured sequence against a fake controller. + +**Not covered:** the inline template JavaScript (left to 2.12's browser smoke test); code that only runs at import (`app.py`'s startup block, `config.py`'s no-`config.json` path); the background-thread entry points (SSE broadcaster start, `/shutdown`'s delayed exit); `_ble_thread`'s handlers for errors escaping asyncio's own `run_forever()` and task cleanup; and `run.py`'s Windows branch and `__main__` guard. --- @@ -187,12 +206,21 @@ All user-tunable settings are now loaded from an optional `config.json` file, wi --- -### 2.7 Continuous Integration -- **Status:** Planned -- **Priority:** Medium -- **Problem:** There's no GitHub Actions workflow; nothing runs automatically on push/PR, even though a `pytest` suite now exists (2.5). -- **Solution:** A GitHub Actions workflow that installs dependencies and runs the test suite plus a linter, on every push and PR. -- **Implementation:** `.github/workflows/ci.yml` running on `ubuntu-latest`, `pip install -r requirements.txt -r requirements-dev.txt`, then `pytest` and a linter (e.g. `ruff`). +### ✅ 2.7 Continuous Integration +**Status:** ✅ Complete +**Changelog:** `[1.9.0]` +**Priority:** Medium +**Files Modified:** `.github/workflows/ci.yml`, `.gitlab-ci.yml`, `requirements-dev.txt`, `ruff.toml` + +Pull/merge requests and pushes to `main`/`development` run the lint and test suite on both GitHub Actions and GitLab CI. + +| Feature | Description | +|---|---| +| **GitHub Actions** | `.github/workflows/ci.yml` on `ubuntu-latest`, Python 3.10 (the supported floor) and 3.13. Pushes run only on `main`/`development`, so a PR branch isn't tested twice | +| **GitLab CI** | `.gitlab-ci.yml` on the `python:3.12` image. Runs a merge-request pipeline when an MR is open, otherwise a branch pipeline, and shows the coverage percentage and a Cobertura report in the MR | +| **Steps** | `pip install -r requirements.txt -r requirements-dev.txt`, `ruff check .`, `python -m pytest --cov=.`. CI reports coverage but never fails on it | +| **Lint** | `ruff` pinned in `requirements-dev.txt`, since its default rule set changes between releases. `pyproject.toml` ignores only rules that flag deliberate project style (root logger, catch-all excepts around BLE I/O, naive local datetimes, a `ValueError` raised to share an `except` clause) | +| **Linux only** | BLE is fully mocked, so no Bluetooth hardware or OS-specific stack is needed. `tests/test_run.py` assumes POSIX (macOS/Linux) | --- @@ -207,6 +235,7 @@ All user-tunable settings are now loaded from an optional `config.json` file, wi ### ✅ 2.9 SQLite Session Storage **Status:** ✅ Complete +**Changelog:** `[1.8.0]` **Priority:** Medium **Files Modified:** `app.py`, `storage.py`, `units.py`, `samples.py`, `config.py`, `config.json.example`, `.gitignore`, `tests/` @@ -219,7 +248,7 @@ Session data lives in a local SQLite database (`walkingdad.db`, configurable via | **Live Lifecycle** | Row created at Start; pauses recorded at manual pause, step-off auto-pause, and Bluetooth drop; completed at End, stale-pause auto-end (1.5), or graceful shutdown | | **Samples** | ~1/s while walking, at most 1 per 5 s while paused (in practice every idle-watchdog ping, ~10 s), plus one at the moment of each manual or auto pause, buffered in memory and flushed on the existing 5 s `_save_session_state()` cadence, so a crash loses at most ~5 s. Storage failures are logged, never allowed to stop the belt or block BLE handling | | **Crash Recovery Link** | `session_state.json` carries the `session_id`: Restore continues the same row (downtime recorded as a `shutdown` pause), Discard deletes it. Unreferenced `active` rows are swept at startup: completed from their last sample, or deleted if they have none | -| **JSON Migration** | One-time, automatic, single transaction: backup to `session_history.json.bak-`, then rename to `.migrated`. Unparseable records are logged and skipped; an unreadable file is left untouched for a later retry | +| **JSON Migration** | One-time, automatic, single transaction: backup to `session_history.json.bak-`, then rename to `.migrated` (both in `data/backups/`). Unparseable records are logged and skipped; an unreadable file is left untouched for a later retry | | **Unchanged Surface** | `units.legacy_record()` rebuilds the pre-SQLite record shape, so the history table, Apple Health export, and CSV columns are identical (CSV gains trailing `id`, `has_samples`) | --- @@ -238,23 +267,23 @@ Session data lives in a local SQLite database (`walkingdad.db`, configurable via - **Priority:** Medium - **Problem:** Pinning versions (2.6) fixes what's installed but doesn't catch a known CVE in whatever gets pinned, or a new one disclosed later against an already-pinned version. - **Solution:** Enable Dependabot security alerts on the repo, and/or run `pip-audit` against `requirements.txt` in CI (2.7). -- **Implementation:** A `.github/dependabot.yml` for version-update PRs plus security alerts; a `pip-audit` step added to the CI workflow from 2.7. +- **Implementation:** A `.github/dependabot.yml` for version-update PRs plus security alerts; a `pip-audit` step added to both CI configs from 2.7. --- ### 2.12 Integration Smoke Test - **Status:** Planned - **Priority:** Medium -- **Problem:** 2.5's unit tests cover pure functions, but nothing exercises the actual Start → Pause → Resume → End route flow end to end, so a template or routing regression could still slip through. +- **Problem:** 2.5's tests drive each route through Flask's test client, but nothing loads the rendered pages in a real browser and clicks through Start → Pause → Resume → End, so a template JavaScript or front-end wiring regression could still slip through. - **Solution:** One browser-driven smoke test that boots the app against a mocked BLE device/controller and clicks through the full session lifecycle, asserting each screen renders and each transition lands where expected. -- **Implementation:** Playwright (or Selenium) driving a test instance of the Flask app with `controller`/`BleakScanner` mocked out; run as part of the CI workflow from 2.7. +- **Implementation:** Playwright (or Selenium) driving a test instance of the Flask app with `controller`/`BleakScanner` mocked out; run as part of the CI configs from 2.7. --- ### 2.13 CONTRIBUTING.md - **Status:** Planned - **Priority:** Low -- **Problem:** There's no documented contribution process or versioning policy, which matters more once 2.7 (CI) and 2.5 (Unit Tests) make outside PRs realistically reviewable. +- **Problem:** There's no documented contribution process or versioning policy, which matters more now that 2.7 (CI) and 2.5 (Unit Tests) make outside PRs realistically reviewable. - **Solution:** A `CONTRIBUTING.md` covering local setup, how to run tests/lint, commit/PR expectations, and the project's versioning policy (semver against `CHANGELOG.md`). - **Implementation:** Plain markdown doc, no tooling. @@ -264,6 +293,7 @@ Session data lives in a local SQLite database (`walkingdad.db`, configurable via ### ✅ 3.1 Dark Mode **Status:** ✅ Complete +**Changelog:** `[1.0.0]` **Priority:** Medium **Files Modified:** `templates/base.html` @@ -273,12 +303,13 @@ Three-state theme toggle (Light → Dark → System) with `localStorage` persist ### ✅ 3.2 Server-Sent Events for Real-Time Updates **Status:** ✅ Complete +**Changelog:** `[1.6.0]` **Priority:** Medium **Files Modified:** `app.py`, `run.py`, `config.py`, `config.json.example`, `templates/base.html`, `templates/active_session.html`, `templates/paused_session.html`, `templates/start_session.html`, `templates/settings.html` Replaced the three templates' `setInterval(fetch('/stats'))` polling loops (1.5s / 3s cadences) with a single `/stats_stream` Server-Sent Events endpoint. A daemon thread broadcasts a stats snapshot to all subscribers once a second via thread-safe per-client queues, independent of whether the belt is running, so active, paused, and start screens all get uniform live updates. `/stats` is kept unchanged for compatibility; both routes now share one `_build_stats_payload()` helper. -Shipping this surfaced a real gap it needed to close first: a dead BLE connection while paused or idle went undetected entirely, since nothing was polling for liveness outside an active session. That's now covered by a dedicated idle/paused connection watchdog with its own staleness threshold, serialized against belt commands, the active stats poll, and speed changes via a lock recreated per connection attempt. See `CHANGELOG.md` `[1.6.0]` for details. +Shipping this surfaced a real gap it needed to close first: a dead BLE connection while paused or idle went undetected entirely, since nothing was polling for liveness outside an active session. That's now covered by a dedicated idle/paused connection watchdog with its own staleness threshold, serialized against belt commands, the active stats poll, and speed changes via a lock recreated per connection attempt. --- @@ -303,7 +334,7 @@ Shipping this surfaced a real gap it needed to close first: a dead BLE connectio - **Problem:** The app always displays imperial units (mph, miles). Users who prefer metric must mentally convert or edit code constants. - **Solution:** Add a unit toggle (Imperial ↔ Metric) in the header that switches between mph/miles and km/h/km in real time. Store preference in `localStorage`. - **Implementation:** - - Return both imperial and metric values from `/stats` JSON endpoint + - Return both imperial and metric values from `_build_stats_payload()` (shared by `/stats_stream` and `/stats`) - Frontend toggles display based on user preference - Add toggle button next to the theme toggle in the header @@ -311,6 +342,7 @@ Shipping this surfaced a real gap it needed to close first: a dead BLE connectio ### ✅ 3.5 Session History Log **Status:** ✅ Complete +**Changelog:** `[1.2.0]` **Priority:** Medium **Files Modified:** `app.py`, `templates/start_session.html`, `templates/active_session.html`, `templates/paused_session.html`, `templates/base.html`, `.gitignore` @@ -349,19 +381,21 @@ Stores completed sessions and displays a summary table on the start screen. Incl ### ✅ 3.8 Selectable Color Themes **Status:** ✅ Complete +**Changelog:** `[1.7.0]` **Priority:** Medium **Files Modified:** `templates/base.html` -10 standard color themes (Slate default, plus 9 hues run in even 40° spectral steps: Red, Amber, Lime, Forest, Teal, Cyan, Blue, Violet, Pink) and 5 "special" themes (Virginia Tech, Bloom, Tide, Harvest, Frost), selectable independently of the Light/Dark/System toggle via a palette icon in the header. Each theme tints the whole surface (backgrounds, cards, browser chrome) through CSS custom properties, not just accent buttons. Special themes add a two-tone swatch, a heading font (fetched only if selected), and, except Virginia Tech, a non-interactive ambient effect (falling petals/snow/leaves, scuttling crabs, glowing eyes) that respects `prefers-reduced-motion`. See `CHANGELOG.md` `[1.7.0]` for details. +10 standard color themes (Slate default, plus 9 hues run in even 40° spectral steps: Red, Amber, Lime, Forest, Teal, Cyan, Blue, Violet, Pink) and 5 "special" themes (Virginia Tech, Bloom, Tide, Harvest, Frost), selectable independently of the Light/Dark/System toggle via a palette icon in the header. Each theme tints the whole surface (backgrounds, cards, browser chrome) through CSS custom properties, not just accent buttons. Special themes add a two-tone swatch, a heading font (fetched only if selected), and, except Virginia Tech, a non-interactive ambient effect (falling petals/snow/leaves, scuttling crabs, glowing eyes) that respects `prefers-reduced-motion`. --- ### ✅ 3.9 Console-Style Interface Redesign **Status:** ✅ Complete +**Changelog:** `[1.7.0]` **Priority:** Medium **Files Modified:** `templates/base.html`, `templates/active_session.html`, `templates/paused_session.html`, `templates/start_session.html`, `templates/connecting.html`, `static/favicon.ico`, `static/apple-touch-icon.png` -Active, Paused, and Start screens redesigned to read like the WalkingPad's own onboard display: one large tabular-digit instrument-face reading (Speed while walking, elapsed time while paused, a Start control when idle) plus a compact Time/Distance/Steps/Calories readout strip below, with a brief mechanical tick animation on value changes and a colored connection status LED next to the logo. Also added a favicon and "Add to Home Screen" icon using the app's own logo mark instead of the browser default. See `CHANGELOG.md` `[1.7.0]` for details. +Active, Paused, and Start screens redesigned to read like the WalkingPad's own onboard display: one large tabular-digit instrument-face reading (Speed while walking, elapsed time while paused, a Start control when idle) plus a compact Time/Distance/Steps/Calories readout strip below, with a brief mechanical tick animation on value changes and a colored connection status LED next to the logo. Also added a favicon and "Add to Home Screen" icon using the app's own logo mark instead of the browser default. --- @@ -384,12 +418,13 @@ Active, Paused, and Start screens redesigned to read like the WalkingPad's own o --- -### 3.12 Screen Wake Lock During Active Session -- **Status:** Planned -- **Priority:** Medium -- **Problem:** Nothing actively touches the page while walking, so the browser can dim or lock the screen mid-session, right when a quick glance at speed/distance is most likely. -- **Solution:** Request a screen wake lock while `belt_running` is true, and release it on pause/end. -- **Implementation:** The native [Wake Lock API](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`), no dependency needed. Re-request on `visibilitychange` since the browser releases the lock automatically when the tab is hidden. +### ✅ 3.12 Screen Wake Lock During Active Session +**Status:** ✅ Complete +**Changelog:** `[1.9.0]` +**Priority:** Medium +**Files Modified:** `templates/active_session.html` + +The Active screen requests a native screen wake lock (`navigator.wakeLock`) and re-requests it when the tab becomes visible again; leaving the page (Pause/End) releases it. The API only exists in a secure context, so it works on `localhost` and HTTPS but not on a phone loading the app over LAN HTTP. --- @@ -432,12 +467,32 @@ Active, Paused, and Start screens redesigned to read like the WalkingPad's own o ### 3.17 Tablet-Width Responsive Breakpoint - **Status:** Planned - **Priority:** Low -- **Problem:** `base.html` has exactly one `@media (max-width: 480px)` rule, tuned for phone-width screens. Desktop is the primary use case, but the app's own pitch covers "any browser on your network," and 2.7/3.7 (Touch/Swipe Gesture Controls) already anticipate a phone/tablet as a secondary device, so there's nothing tuned for the tablet width range in between. +- **Problem:** `base.html` has exactly one `@media (max-width: 480px)` rule, tuned for phone-width screens. Desktop is the primary use case, but the app's own pitch covers "any browser on your network," and 3.7 (Touch/Swipe Gesture Controls) already anticipates a phone/tablet as a secondary device, so there's nothing tuned for the tablet width range in between. - **Solution:** Add an intermediate breakpoint (e.g. `max-width: 900px`) tuned for tablet-class screens (iPad-size), rather than jumping straight from desktop layout to the 480px phone rules. - **Implementation:** Audit `console-hero`/`console-strip`/history table layout at common tablet widths (768-1024px) and add a second `@media` block alongside the existing 480px one. --- +### ✅ 3.18 Transition Hint Shows on Every Button Press +**Status:** ✅ Complete +**Changelog:** `[1.9.0]` +**Priority:** Medium +**Files Modified:** `templates/base.html`, `templates/active_session.html`, `templates/paused_session.html`, `templates/start_session.html` + +Submitting a form only disables and dims the buttons now; each page's `#transitioning-hint` follows the SSE `belt_transitioning` flag alone, so it shows only during a real start/pause/resume/end belt sequence, with matching text. A `submitting` flag in `base.html` also keeps the buttons disabled through SSE ticks until the page navigates away (cleared on a back/forward-cache restore), closing a double-tap window. The hint still toggles `display`, so the legitimate post-Start/Resume hint shifts the layout briefly; reserving its space was skipped to avoid a permanent gap on the phone layout. + +--- + +### ✅ 3.19 Phone Layout +**Status:** ✅ Complete +**Changelog:** `[1.9.0]` +**Priority:** Medium +**Files Modified:** `templates/base.html`, `templates/active_session.html`, `templates/paused_session.html`, `templates/start_session.html`, `static/manifest.json` + +Added the missing viewport meta, so phones get a real phone layout instead of a scaled-down desktop page. Under 480px: the reading fills the screen with preset and Pause/End rows docked at the bottom in thumb reach, 44px+ tap targets, a single menu button for the header controls, and session history as stacked cards. A web app manifest lets Add to Home Screen launch full-screen. Desktop layout is unchanged. + +--- + ## Stats & Motivation ### 4.1 Estimated Time to Distance Goal @@ -489,16 +544,17 @@ Active, Paused, and Start screens redesigned to read like the WalkingPad's own o ### ✅ 5.1 Apple Health Export via Shortcuts **Status:** ✅ Complete +**Changelog:** `[1.8.0]`, `[1.9.0]` **Priority:** Low **Files Modified:** `app.py`, `config.py`, `config.json.example`, `templates/base.html`, `templates/start_session.html`, `templates/settings.html`, `README.md` Verified against current Apple documentation before building (the original plan below, kept for history, assumed a network-fetch design that turned out to need correcting): no Apple Developer Program membership is needed (a personal Shortcut is never distributed), and critically, the Health app does not exist on macOS as of Tahoe 26. This can only run on the iPhone, not the Mac WalkingDad itself runs on. -Two QR codes, both self-contained (no fetch back to WalkingDad's server): a **setup QR** (a plain iCloud share link, `Share → Copy iCloud Link` in the Shortcuts app) installs the Shortcut once per phone, and a **per-session QR** (`shortcuts://run-shortcut?name=...&input=text&text=`) runs it with that session's data embedded directly in the URL. Hosting the `.shortcut` file on GitHub and using `shortcuts://import-shortcut?url=...` was tried first and abandoned. It reliably failed with "shortcut URL provided was invalid" across every URL encoding/ordering tried by hand, a known, documented unreliability of GitHub-hosted `.shortcut` files with that scheme, not an encoding bug on WalkingDad's end. An iCloud link is Apple's actual supported distribution path. The Shortcut itself (`Get Dictionary from Input` → `Log Workout`, Type Walking, Date/Duration/Calories/Distance bound via Magic Variable) was hand-built in the Shortcuts app and shared from there, since there's no official API to generate a `.shortcut` file programmatically. +Two QR codes, both self-contained (no fetch back to WalkingDad's server): a **setup QR** (a plain iCloud share link, `Share → Copy iCloud Link` in the Shortcuts app) installs the Shortcut once per phone, and a **per-session QR** (`shortcuts://run-shortcut?name=...&input=text&text=`) runs it with that session's data embedded directly in the URL. Hosting the `.shortcut` file on GitHub and using `shortcuts://import-shortcut?url=...` was tried first and abandoned. It reliably failed with "shortcut URL provided was invalid" across every URL encoding/ordering tried by hand, a known, documented unreliability of GitHub-hosted `.shortcut` files with that scheme, not an encoding bug on WalkingDad's end. An iCloud link is Apple's actual supported distribution path. The Shortcut itself (`Get Dictionary from Input` → `Log Workout`, Type Walking, Date/Duration/Calories/Distance bound via Magic Variable, then `Log Health Sample` for Steps) was hand-built in the Shortcuts app and shared from there, since there's no official API to generate a `.shortcut` file programmatically. When rebuilding it, `Log Health Sample`'s Value row for Steps only appears after granting Shortcuts write access to Steps. **Off by default.** An `apple_health_export_enabled` setting (Settings page, styled as a pill/slide toggle) gates the whole feature; when off, neither QR nor the start-screen prompt appears. Turning it on doesn't retroactively surface an old, unrelated session that happened to be most recent before the feature existed: the currently-most-recent session is pre-marked as handled on that specific off→on transition, unless it's from today, in which case it's left showing (plausibly the reason someone would enable the feature mid-session in the first place). -The start screen shows a persistent "Log to Apple Health" banner after a session ends, driven by a `health_logged` flag stored on the session record itself (not a one-shot flash). It survives navigation and reloads, and only clears on an explicit Dismiss or once a newer session supersedes it. This check is independent of the unrelated `history_display_limit` setting (querying the single most recent session directly rather than reusing the display-limited history list), so setting that to `0` doesn't silently disable Apple Health export too. Tapping the banner opens an in-page modal (no navigation) with the per-session QR, with a link inside to swap to the setup QR for anyone who hasn't installed the Shortcut yet. The Settings page also keeps a permanent copy of the setup QR (inside the same collapsing toggle section) for reinstalling later. QR rendering is client-side (`qrcodejs` via CDN, matching how `base.html` already pulls Bootstrap/Icons/Fonts), no new Python dependency. +The start screen shows a persistent "Log to Apple Health" banner after a session ends, driven by a `health_logged` flag stored on the session record itself (not a one-shot flash). It survives navigation and reloads, and clears once the Shortcut reports success (an `x-success` callback to `/health_logged/`), on an explicit Dismiss (stored as dismissed, not logged), or once a newer session supersedes it. This check is independent of the unrelated `history_display_limit` setting (querying the single most recent session directly rather than reusing the display-limited history list), so setting that to `0` doesn't silently disable Apple Health export too. Tapping the banner opens an in-page modal (no navigation) with the per-session QR, with a link inside to swap to the setup QR for anyone who hasn't installed the Shortcut yet. The Settings page also keeps a permanent copy of the setup QR (inside the same collapsing toggle section) for reinstalling later. QR rendering is client-side (`qrcodejs` via CDN, matching how `base.html` already pulls Bootstrap/Icons/Fonts), no new Python dependency. This also gave 3.6 (QR Code for LAN Access) a proven client-side QR-rendering approach to reuse rather than starting from scratch. @@ -531,6 +587,15 @@ This also gave 3.6 (QR Code for LAN Access) a proven client-side QR-rendering ap --- +### 5.4 Native HealthKit Companion App +- **Status:** Planned +- **Priority:** Low +- **Problem:** Shortcuts can't log what KS Fit (the WalkingPad's own app) does. As of iOS 27, `Log Workout` takes only type, date, duration, calories and distance: "Walking" has no indoor variant and there's no metadata field, so every session shows as an outdoor walk. `Log Health Sample` has no End Date, so steps are stamped at the session start, and no Shortcuts action (built-in, or third-party such as Actions or Toolbox Pro) can attach samples to a workout. +- **Solution:** A small iOS app whose only job is one Shortcuts action, "Log Treadmill Walk", that takes the same JSON WalkingDad already sends. The installed Shortcut swaps `Log Workout` + `Log Health Sample` for that one action; WalkingDad and the `x-success` callback don't change. +- **Implementation:** An App Intent using `HKWorkoutBuilder`: `.walking` with `HKMetadataKeyIndoorWorkout = true`, energy and distance samples, and a step-count sample spanning start to end, added to the builder so they're attached to the workout. HealthKit is available to a free Apple ID (Personal Team in Xcode; Apple's "Supported capabilities" table lists it for free accounts), so no $99 membership is needed. The catch is that free provisioning expires every 7 days and has to be reinstalled from Xcode on a Mac, and it can't be distributed to anyone else. Prototype first: KS Fit's own workouts don't attach steps either, and Fitness may not show workout steps even when attached, so check that the Indoor Walk label and steps actually appear before committing to the 7-day reinstalls. + +--- + ## Notes - Items are grouped by theme (Area), not by execution order; each item's own **Priority** is what signals urgency, not its Area or position in the list. diff --git a/app.py b/app.py index 3316946..67b89b9 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ import os import queue import signal +import socket import threading import time import urllib.parse @@ -14,15 +15,31 @@ from datetime import datetime, timedelta from bleak import BleakScanner -from flask import Flask, render_template, redirect, url_for, jsonify, make_response, request, Response +from flask import ( + Flask, + Response, + jsonify, + make_response, + redirect, + render_template, + request, + url_for, +) from ph4_walkingpad.pad import Controller, WalkingPad import config import storage from config import ( - BLE_DEVICE_NAME, KCAL_PER_MILE, MAX_SPEED_KMH, MIN_SPEED_KMH, - SPEED_STEP, SLOW_WALK_SPEED_KMH, RESUME_GRACE_PERIOD_SECONDS, - HISTORY_DISPLAY_LIMIT, APPLE_HEALTH_SHORTCUT_NAME, APPLE_HEALTH_EXPORT_ENABLED, + APPLE_HEALTH_EXPORT_ENABLED, + APPLE_HEALTH_SHORTCUT_NAME, + BLE_DEVICE_NAME, + HISTORY_DISPLAY_LIMIT, + KCAL_PER_MILE, + MAX_SPEED_KMH, + MIN_SPEED_KMH, + RESUME_GRACE_PERIOD_SECONDS, + SLOW_WALK_SPEED_KMH, + SPEED_STEP, STALE_PAUSE_TIMEOUT_MINUTES, ) from samples import SampleBuffer, summary_from_samples @@ -75,11 +92,11 @@ def format_seconds_to_hms(total_seconds: int) -> str: _belt_transitioning = False # True while a belt sequence is in flight; exposed in /stats for UI _auto_reconnect_task: asyncio.Task | None = None # Track an in-flight auto-reconnect retry loop _speed_change_task: asyncio.Task | None = None # Track an in-flight _locked_change_speed() call -HISTORY_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "session_history.json") # legacy; migrated into the DB at startup -_CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") +HISTORY_FILE = os.path.join(config.DATA_DIR, "session_history.json") # legacy; migrated into the DB at startup +_CONFIG_FILE = config.CONFIG_FILE _session_state_file_lock = threading.Lock() # Protect session_state.json reads/writes -SESSION_STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "session_state.json") +SESSION_STATE_FILE = os.path.join(config.DATA_DIR, "session_state.json") _SESSION_STATE_SAVE_INTERVAL_SECONDS = 5 # ask_stats() only sends a request. The ph4_walkingpad library never returns the reply # synchronously; the real data always lands via the on_cur_status_received notification @@ -379,14 +396,41 @@ def _clear_session_history(): logging.exception("Failed to clear session history") -def _dismiss_health_export(): - """Mark the most recent session as no longer pending an Apple Health export.""" +# {"id", "status"} of the last Apple Health status change, pushed over SSE so other open +# pages (e.g. the desktop prompt while the phone logs) update without a reload. +_last_health_status_change = None + + +def _set_health_status(session_id, status) -> bool: + """Only completed sessions have an Apple Health status; False for anything else or a failed write.""" + global _last_health_status_change try: - recent = storage.list_sessions(limit=1) - if recent: - storage.mark_health_logged(recent[0]["id"]) + row = storage.get_session(session_id) + if not row or row["status"] != "completed": + return False + # A Dismiss from a page that hasn't seen the log yet must not undo it. + if status == storage.HEALTH_DISMISSED and row["health_logged"] == storage.HEALTH_LOGGED: + return True + storage.set_health_status(session_id, status) except Exception: - logging.exception("Failed to dismiss health export flag") + logging.exception("Failed to update Apple Health status") + return False + _last_health_status_change = {"id": session_id, "status": status} + return True + + +def _dismiss_health_export(session_id=None): + """Mark session_id (default: the most recent session) as no longer pending an Apple Health export.""" + if session_id is None: + try: + recent = storage.list_sessions(limit=1) + except Exception: + logging.exception("Failed to dismiss health export flag") + return + if not recent: + return + session_id = recent[0]["id"] + _set_health_status(session_id, storage.HEALTH_DISMISSED) # ── Session State Persistence (crash/restart recovery) ────────────────── @@ -421,7 +465,7 @@ def _save_session_state(): with open(tmp_path, "w") as f: json.dump(snapshot, f, indent=2) os.replace(tmp_path, SESSION_STATE_FILE) - except IOError as exc: + except OSError as exc: logging.error(f"Failed to write session state: {exc}") @@ -436,7 +480,7 @@ def _load_session_state() -> dict | None: if not isinstance(state, dict) or not state.get("session_active"): return None return state - except (json.JSONDecodeError, IOError) as exc: + except (OSError, ValueError) as exc: logging.warning(f"Failed to read session state, ignoring: {exc}") return None @@ -447,7 +491,7 @@ def _clear_session_state(): try: if os.path.exists(SESSION_STATE_FILE): os.remove(SESSION_STATE_FILE) - except IOError as exc: + except OSError as exc: logging.error(f"Failed to clear session state: {exc}") @@ -466,8 +510,9 @@ def _write_config(updates: dict) -> None: _STARTUP_ENABLED = os.environ.get("WALKINGDAD_NO_STARTUP") != "1" if _STARTUP_ENABLED: - storage.init_db(os.path.join(os.path.dirname(os.path.abspath(__file__)), config.DATABASE_PATH)) - storage.migrate_json(HISTORY_FILE) + config.relocate_legacy_files() + storage.init_db(os.path.join(config.DATA_DIR, config.DATABASE_PATH)) + storage.migrate_json(HISTORY_FILE, config.BACKUP_DIR) _pending_restore = _load_session_state() # Check for an interrupted session at startup _sweep_orphaned_sessions((_pending_restore or {}).get("session_id")) @@ -485,7 +530,7 @@ def _write_config(updates: dict) -> None: # ultimately hands off to one of these under the hood. It's also just a # plain https:// link, not the shortcuts:// scheme -- Apple's own "Get # Shortcut" page at the other end handles the import itself. -_APPLE_HEALTH_SHORTCUT_ICLOUD_LINK = "https://www.icloud.com/shortcuts/c832ad7548ac425898fae31920bcb8c3" +_APPLE_HEALTH_SHORTCUT_ICLOUD_LINK = "https://www.icloud.com/shortcuts/fb023c69aa204562afff9587b0b6441a" def _build_setup_shortcut_url() -> str: @@ -495,10 +540,28 @@ def _build_setup_shortcut_url() -> str: return _APPLE_HEALTH_SHORTCUT_ICLOUD_LINK -def _build_log_shortcut_url(session_record: dict) -> str: - """shortcuts://run-shortcut runs the already-installed Shortcut with that - session's data embedded directly in the URL -- no fetch back to this server - needed. Percent-encodes name= and the JSON text= payload (which has its own +def _phone_reachable_base_url() -> str: + """request.host_url, but with a loopback host (the desktop's usual + http://localhost:) swapped for this machine's LAN IP, since a phone + opens this URL. Falls back to request.host_url if no route exists.""" + parts = urllib.parse.urlsplit(request.host_url) + if parts.hostname not in ("localhost", "127.0.0.1", "::1"): + return request.host_url + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("10.254.254.254", 1)) # UDP connect only picks a route; nothing is sent. + ip = s.getsockname()[0] + except OSError: + return request.host_url + port = f":{parts.port}" if parts.port else "" + return f"{parts.scheme}://{ip}{port}/" + + +def _build_log_shortcut_url(session_record: dict, success_url: str) -> str: + """shortcuts://x-callback-url/run-shortcut runs the already-installed + Shortcut with that session's data embedded directly in the URL, then opens + success_url once it finishes, which marks the session logged. Percent-encodes + name= and the JSON text= payload (which has its own unsafe characters -- spaces, quotes, braces), but leaves ":" and "/" alone: a raw, un-percent-encoded space isn't valid inside a URI at all (unlike : and /, which are allowed unencoded in a query component per RFC 3986), and @@ -510,20 +573,27 @@ def _build_log_shortcut_url(session_record: dict) -> str: "distance_km": session_record["distance_km"], "distance_mi": session_record["distance_mi"], "calories": session_record["calories"], + "steps": session_record["steps"], }) name = urllib.parse.quote(APPLE_HEALTH_SHORTCUT_NAME, safe=":/") text = urllib.parse.quote(payload, safe=":/") - return f"shortcuts://run-shortcut?name={name}&input=text&text={text}" + success = urllib.parse.quote(success_url, safe="") + return f"shortcuts://x-callback-url/run-shortcut?name={name}&input=text&text={text}&x-success={success}" + + +def _health_log_url(session_record: dict, base_url: str) -> str: + success_url = base_url.rstrip("/") + url_for("health_logged", session_id=session_record["id"]) + return _build_log_shortcut_url(session_record, success_url) # ── Context processor so templates always know flags ──────────────────── @app.context_processor def inject_flags(): - return dict( - connected=connected, connecting=connecting, connection_failed=connection_failed, - apple_health_shortcut_name=APPLE_HEALTH_SHORTCUT_NAME, - setup_shortcut_url=_build_setup_shortcut_url(), - ) + return { + "connected": connected, "connecting": connecting, "connection_failed": connection_failed, + "apple_health_shortcut_name": APPLE_HEALTH_SHORTCUT_NAME, + "setup_shortcut_url": _build_setup_shortcut_url(), + } # ── BLE helpers ───────────────────────────────────────────────────────── @@ -666,7 +736,7 @@ def process_status_packet(dev_dist: float, dev_steps: int, dev_speed: float): passive BLE notification callback (_handle_status_update), either path landing here means the connection is alive. """ - global belt_running, resume_speed_kmh, _resume_grace_deadline + global belt_running, resume_speed_kmh global current_speed_kmh, current_distance_km, current_steps, current_calories global _last_dev_dist, _last_dev_steps, _last_status_update_monotonic global current_session_active_seconds, _last_moving_packet_monotonic @@ -702,20 +772,20 @@ def process_status_packet(dev_dist: float, dev_steps: int, dev_speed: float): # Fires on any zero reading once the grace window has passed, not only on a # moving-to-zero transition: a belt that stopped (or never started) inside # the window would otherwise leave the session "walking" indefinitely. - if time.time() > _resume_grace_deadline and not _belt_transitioning: - if belt_running and new_reported_speed_kmh == 0: - logging.info("Belt has stopped unexpectedly. Auto-pausing session.") - - # Use the OLDEST speed from history to ignore the deceleration phase. - if speed_history: - resume_speed_kmh = speed_history[0] # Use the first (oldest) item - elif current_speed_kmh > 0: - # Walked only at or below MIN_SPEED_KMH, so nothing was recorded in history - resume_speed_kmh = MIN_SPEED_KMH - # else the belt never moved this segment: keep the speed it was asked for + if (time.time() > _resume_grace_deadline and not _belt_transitioning + and belt_running and new_reported_speed_kmh == 0): + logging.info("Belt has stopped unexpectedly. Auto-pausing session.") - belt_running = False - just_auto_paused = True + # Use the OLDEST speed from history to ignore the deceleration phase. + if speed_history: + resume_speed_kmh = speed_history[0] # Use the first (oldest) item + elif current_speed_kmh > 0: + # Walked only at or below MIN_SPEED_KMH, so nothing was recorded in history + resume_speed_kmh = MIN_SPEED_KMH + # else the belt never moved this segment: keep the speed it was asked for + + belt_running = False + just_auto_paused = True # Cumulative stats accumulation if dev_dist < _last_dev_dist: @@ -973,9 +1043,8 @@ async def _cancel_task(task: asyncio.Task | None) -> None: Shared by every module-level task this app tracks (stats monitor, idle watchdog, in-flight belt sequence) -- callers pass their own task - variable; this never reassigns it, matching the previous per-task - helpers' behavior (the global gets overwritten next time a new task is - created, same as before). + variable; this never reassigns it (the global gets overwritten next + time a new task is created). """ if task and not task.done(): task.cancel() @@ -1054,8 +1123,8 @@ async def _wake_and_start_belt(target_speed_kmh: float | None = None, try_light_ try_light_wake=True (Resume only) first attempts _try_light_wake(). Falls back to _full_wake_sequence() -- identical to the - try_light_wake=False path -- if that can't be confirmed, so reliability - is never worse than before this change, only sometimes gentler on the + try_light_wake=False path -- if that can't be confirmed, so it's never + less reliable than the full sequence, only sometimes gentler on the WalkingPad's own display. """ async def _body(): @@ -1233,7 +1302,6 @@ def _handle_disconnect(client): depending on platform/backend. """ global connected, belt_running, connecting, connection_failed - global _stats_monitor_task, _idle_watchdog_task, _belt_sequence_task, _speed_change_task with _start_ble_thread_lock: # Idempotent: this can legitimately fire 2-3 times for the same @@ -1331,7 +1399,7 @@ def _handle_signal_shutdown(signum, frame): return _shutting_down = True - # Set UI-visible flag early so the next /stats poll can inform the browser + # Set UI-visible flag early so the next /stats_stream tick can inform the browser _server_stopping = True logging.info(f"Received signal {signum}, initiating graceful shutdown...") @@ -1350,12 +1418,29 @@ def _handle_signal_shutdown(signum, frame): except Exception as exc: logging.debug(f"Error stopping BLE loop from signal: {exc}") - # Give the browser ~2 s to receive the last /stats response (stopping: true), + # Give the browser ~2 s to receive the last /stats_stream frame (stopping: true), # render the shutdown message, and then force-kill the process. time.sleep(2) os._exit(0) +# ── CSRF guard (ROADMAP 2.2) ──────────────────────────────────────────── +@app.before_request +def _block_cross_site_posts(): + """LAN devices are trusted by design (the app is LAN-only); this only stops + another website open in a browser from driving the treadmill. Browsers + send Origin on every cross-origin POST, including plain form submits; + Sec-Fetch-Site still catches it when an extension or proxy strips Origin. + "same-site" is rejected too: another port on the same host counts as same-site.""" + if request.method in ("GET", "HEAD"): + return None + origin = request.headers.get("Origin") + if (request.headers.get("Sec-Fetch-Site") in ("cross-site", "same-site") + or (origin and origin != request.host_url.rstrip("/"))): + return "Cross-site request blocked.", 403 + return None + + # ── Flask routes ──────────────────────────────────────────────────────── @app.route("/") def root(): @@ -1378,9 +1463,14 @@ def root(): pending_health_export = ( APPLE_HEALTH_EXPORT_ENABLED and bool(most_recent_session) - and not most_recent_session[0].get("health_logged", False) + and most_recent_session[0]["health_status"] == storage.HEALTH_PENDING ) - log_shortcut_url = _build_log_shortcut_url(most_recent_session[0]) if pending_health_export else None + if APPLE_HEALTH_EXPORT_ENABLED: + base_url = _phone_reachable_base_url() + for record in history: + if record["health_status"] != storage.HEALTH_LOGGED: + record["log_shortcut_url"] = _health_log_url(record, base_url) + log_shortcut_url = _health_log_url(most_recent_session[0], base_url) if pending_health_export else None pending_restore = None if _pending_restore: @@ -1394,6 +1484,8 @@ def root(): return render_template( "start_session.html", time_active="0:00:00", history=history, pending_restore=pending_restore, pending_health_export=pending_health_export, log_shortcut_url=log_shortcut_url, + health_session_id=most_recent_session[0]["id"] if pending_health_export else None, + health_export_enabled=APPLE_HEALTH_EXPORT_ENABLED, ) template = "active_session.html" if belt_running else "paused_session.html" @@ -1428,10 +1520,9 @@ def _end_session(stale: bool = False): async def _end_belt_sequence(): global _belt_sequence_task, _belt_transitioning # Registers itself as _belt_sequence_task and sets - # _belt_transitioning like its three siblings (start/pause/resume) - # -- previously it did neither, the one belt sequence that didn't - # follow the pattern, so nothing else could observe or cancel an - # in-flight End Session the way it can for the others. + # _belt_transitioning like its three siblings (start/pause/resume), + # so an in-flight End Session can be observed and cancelled the + # same way as the others. await _cancel_task(_belt_sequence_task) _belt_sequence_task = asyncio.current_task() _belt_transitioning = True @@ -1531,11 +1622,37 @@ def clear_history(): # ── Dismiss Apple Health Export Prompt ─────────────────────────────────── @app.route("/dismiss_health_export", methods=["POST"]) def dismiss_health_export(): - """Mark the most recent session as dismissed from the Apple Health export prompt.""" - _dismiss_health_export() + """Dismiss the prompt's session (sent by the banner), so a session that ended + meanwhile on another device isn't dismissed in its place.""" + _dismiss_health_export(request.form.get("session_id")) return jsonify({"status": "dismissed"}) +@app.route("/delete_session/", methods=["POST"]) +def delete_session(session_id): + """Delete one completed session (and its pauses/samples). Never an + in-progress or awaiting-restore one, same rule as Clear History.""" + try: + row = storage.get_session(session_id) + if not row or row["status"] != "completed": + return jsonify({"status": "not_found"}), 404 + storage.delete_session(session_id) + except Exception: + logging.exception("Failed to delete session") + return jsonify({"status": "error"}), 500 + return jsonify({"status": "deleted"}) + + +@app.route("/health_logged/") +def health_logged(session_id): + """x-success target of the Log to Apple Health link. A GET because Shortcuts + opens it as a Safari navigation; the CSRF guard skips GETs, so another + website could at most mark one session as logged.""" + if not _set_health_status(session_id, storage.HEALTH_LOGGED): + return "Session not found, or it couldn't be marked as logged.", 404 + return redirect(url_for("root")) + + # Waitress won't necessarily start (or will be unusably starved) with too # few threads -- unlike the other numeric settings, a bad value here doesn't # just misbehave one subsystem, it can take down the whole app on next @@ -1653,7 +1770,7 @@ def settings_page(): ) -@app.route("/reconnect") +@app.route("/reconnect", methods=["POST"]) def reconnect(): if not connected and not connecting: _start_ble_thread() @@ -1726,7 +1843,7 @@ def discard_session(): def start_session(): """Begin a new session: reset counters, start belt, launch stats monitor.""" global session_active, belt_running, current_distance_km, current_steps, current_calories, resume_speed_kmh - global current_session_active_seconds, _stats_monitor_task, _session_start_time, current_speed_kmh + global current_session_active_seconds, _session_start_time, current_speed_kmh global _resume_grace_deadline, _pending_restore, _last_moving_packet_monotonic if not connected: @@ -1844,7 +1961,10 @@ async def _pause_belt_sequence(): _belt_sequence_task = None _belt_transitioning = False - asyncio.run_coroutine_threadsafe(_pause_belt_sequence(), ble_loop) + try: + asyncio.run_coroutine_threadsafe(_pause_belt_sequence(), ble_loop) + except Exception as exc: + logging.error(f"Failed to queue pause sequence: {exc}") return redirect(url_for("root")) @@ -1852,8 +1972,7 @@ async def _pause_belt_sequence(): @app.route("/resume", methods=["POST"], endpoint="resume") @app.route("/resume_session", methods=["POST"], endpoint="resume_session") def resume_session(): - global belt_running, _resume_grace_deadline, session_active, _stats_monitor_task - global _last_moving_packet_monotonic + global belt_running, _resume_grace_deadline, _last_moving_packet_monotonic with _session_state_lock: if not session_active: @@ -1921,13 +2040,7 @@ async def _resume_belt_sequence(): @app.route("/decrease_speed", methods=["POST"]) def decrease_speed(): """Decrease the belt speed by one step.""" - if not belt_running: - return redirect(url_for("root")) - - new_speed_kmh = max(MIN_SPEED_KMH, current_speed_kmh - SPEED_STEP) - dev_speed = int(new_speed_kmh * 10) - asyncio.run_coroutine_threadsafe(_locked_change_speed(dev_speed), ble_loop) - return redirect(url_for("root")) + return _set_preset_speed(max(MIN_SPEED_KMH, current_speed_kmh - SPEED_STEP)) def _set_preset_speed(speed_kmh: float): @@ -1936,7 +2049,10 @@ def _set_preset_speed(speed_kmh: float): return redirect(url_for("root")) dev_speed = int(speed_kmh * 10) - asyncio.run_coroutine_threadsafe(_locked_change_speed(dev_speed), ble_loop) + try: + asyncio.run_coroutine_threadsafe(_locked_change_speed(dev_speed), ble_loop) + except Exception as exc: + logging.error(f"Failed to queue speed change: {exc}") return redirect(url_for("root")) @@ -1955,13 +2071,7 @@ def slow_speed(): @app.route("/increase_speed", methods=["POST"]) def increase_speed(): """Increase the belt speed by one step.""" - if not belt_running: - return redirect(url_for("root")) - - new_speed_kmh = min(MAX_SPEED_KMH, current_speed_kmh + SPEED_STEP) - dev_speed = int(new_speed_kmh * 10) - asyncio.run_coroutine_threadsafe(_locked_change_speed(dev_speed), ble_loop) - return redirect(url_for("root")) + return _set_preset_speed(min(MAX_SPEED_KMH, current_speed_kmh + SPEED_STEP)) @app.route("/max_speed", methods=["POST"]) @@ -1975,21 +2085,22 @@ def _build_stats_payload() -> dict: """Build the stats snapshot dict from current global state. Single source of truth for the wire payload shape, shared by the - polling /stats endpoint and the SSE broadcaster. + /stats endpoint and the SSE broadcaster. """ - return dict( - is_connected=connected, - connection_failed=connection_failed, - session_active=session_active, - is_running=belt_running, - belt_transitioning=_belt_transitioning, - speed=round(current_speed_kmh * KM_TO_MI, 1), - distance=round(current_distance_km * KM_TO_MI, 2), - steps=current_steps, - calories=round(current_calories), - time_active=format_seconds_to_hms(current_session_active_seconds), - stopping=_server_stopping, - ) + return { + "is_connected": connected, + "connection_failed": connection_failed, + "session_active": session_active, + "is_running": belt_running, + "belt_transitioning": _belt_transitioning, + "speed": round(current_speed_kmh * KM_TO_MI, 1), + "distance": round(current_distance_km * KM_TO_MI, 2), + "steps": current_steps, + "calories": round(current_calories), + "time_active": format_seconds_to_hms(current_session_active_seconds), + "stopping": _server_stopping, + "health_status_changed": _last_health_status_change, + } @app.route("/stats", endpoint="get_stats") @@ -2088,7 +2199,7 @@ def shutdown(): return jsonify({"status": "shutting_down"}) _shutting_down = True - # Set UI-visible flag immediately so the next /stats poll informs the browser + # Set UI-visible flag immediately so the next /stats_stream tick informs the browser _server_stopping = True logging.info("Graceful shutdown initiated via HTTP...") @@ -2113,7 +2224,7 @@ def shutdown(): # Use os._exit(0) here because Waitress catches SystemExit from sys.exit(0) # and continues running, which would prevent the server from actually stopping. def _deferred_exit(): - time.sleep(5) # Give browser time to receive /stats with stopping:true + time.sleep(5) # Give browser time to receive a /stats_stream frame with stopping:true logging.info("Exiting process after graceful shutdown...") os._exit(0) @@ -2121,14 +2232,9 @@ def _deferred_exit(): return resp -# ── Signal handlers for graceful shutdown on Ctrl+C / SIGTERM ────────── -signal.signal(signal.SIGTERM, _handle_signal_shutdown) -signal.signal(signal.SIGINT, _handle_signal_shutdown) - # ── Atexit handler as safety net ──────────────────────────────────────── def _atexit_cleanup(): """Safety net: attempt to stop the belt and disconnect BLE on process exit.""" - global _shutting_down, ble_loop if _shutting_down: return # Already handled gracefully logging.info("atexit: performing emergency cleanup...") @@ -2138,10 +2244,12 @@ def _atexit_cleanup(): except Exception as exc: logging.error(f"atexit cleanup error: {exc}") -atexit.register(_atexit_cleanup) -# ── Kick off BLE thread ────────────────────────────────────────────────── -# The server is no longer started here. This just pre-starts the BLE thread. +# ── Startup: exit handlers (Ctrl+C / SIGTERM / atexit), BLE thread, SSE broadcaster ── +# The web server itself is started by run.py. Skipped under WALKINGDAD_NO_STARTUP=1 (tests). if _STARTUP_ENABLED: + signal.signal(signal.SIGTERM, _handle_signal_shutdown) + signal.signal(signal.SIGINT, _handle_signal_shutdown) + atexit.register(_atexit_cleanup) _start_ble_thread() _start_sse_broadcaster() diff --git a/apple_health/log-walkingdad-workout.shortcut b/apple_health/log-walkingdad-workout.shortcut index 0948714..941a6c5 100644 Binary files a/apple_health/log-walkingdad-workout.shortcut and b/apple_health/log-walkingdad-workout.shortcut differ diff --git a/config.py b/config.py index 72e3bc6..f114f66 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,5 @@ import json +import logging import os _DEFAULTS = { @@ -19,11 +20,23 @@ "database_path": "walkingdad.db", } -_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") -_overrides = {} -if os.path.isfile(_path): - with open(_path) as _f: - _overrides = json.load(_f) +APP_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(APP_DIR, "data") +BACKUP_DIR = os.path.join(DATA_DIR, "backups") +CONFIG_FILE = os.path.join(DATA_DIR, "config.json") +# Where versions before data/ kept it. Read until relocate_legacy_files() moves it at startup. +_LEGACY_CONFIG_FILE = os.path.join(APP_DIR, "config.json") + + +def _load_overrides(*paths): + for path in paths: + if os.path.isfile(path): + with open(path) as f: + return json.load(f) + return {} + + +_overrides = _load_overrides(CONFIG_FILE, _LEGACY_CONFIG_FILE) def _get(key): @@ -45,3 +58,27 @@ def _get(key): APPLE_HEALTH_SHORTCUT_NAME: str = _get("apple_health_shortcut_name") APPLE_HEALTH_EXPORT_ENABLED: bool = _get("apple_health_export_enabled") DATABASE_PATH: str = _get("database_path") + + +def relocate_legacy_files(): + """Move runtime files that versions before data/ kept next to the code into + DATA_DIR (JSON-migration leftovers into BACKUP_DIR). Never overwrites.""" + os.makedirs(BACKUP_DIR, exist_ok=True) + # Each group moves all-or-nothing: a database separated from its -wal loses uncommitted sessions. + groups = [[(name, DATA_DIR)] for name in ("config.json", "session_state.json", "session_history.json")] + if not os.path.isabs(DATABASE_PATH): + groups.append([(DATABASE_PATH + suffix, DATA_DIR) for suffix in ("", "-wal", "-shm")]) + groups += [[(name, BACKUP_DIR)] for name in os.listdir(APP_DIR) if name.startswith("session_history.json.")] + for group in groups: + pairs = [(os.path.join(APP_DIR, name), os.path.join(dest_dir, name)) for name, dest_dir in group] + blocked = [dest for _, dest in pairs if os.path.exists(dest)] + pairs = [(src, dest) for src, dest in pairs if os.path.isfile(src)] + if not pairs: + continue + if blocked: + logging.warning(f"Not moving {', '.join(src for src, _ in pairs)}: {', '.join(blocked)} already exists") + continue + for src, dest in pairs: + os.makedirs(os.path.dirname(dest), exist_ok=True) + os.replace(src, dest) + logging.info(f"Moved {src} to {dest}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6466e49 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] + +[tool.ruff.lint] +# Deliberate style here: root logger, catch-all excepts around BLE I/O, naive local +# datetimes for session dates, and a ValueError raised to share an except clause. +ignore = ["LOG015", "BLE001", "DTZ005", "TRY004"] + +[tool.coverage.run] +omit = ["tests/*"] diff --git a/requirements-dev.txt b/requirements-dev.txt index e079f8a..5cc0dc9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,6 @@ pytest +pytest-cov +# [toml]: coverage reads pyproject.toml only with this extra on Python 3.10. +coverage[toml] +# Pinned: ruff's default rule set changes between releases. +ruff==0.16.10 diff --git a/run.py b/run.py index e295a6e..51b6ffb 100644 --- a/run.py +++ b/run.py @@ -40,11 +40,11 @@ def check_port(): HOST:PORT, instead of a bare traceback after the browser opens.""" # getaddrinfo wraps out-of-range ports (70000 -> 4464), so check first. if type(PORT) is not int or not 1 <= PORT <= 65535: - sys.exit(f'Can\'t start: "port" in config.json must be a whole number from 1 to 65535, got {PORT!r}.') + sys.exit(f'Can\'t start: "port" in data/config.json must be a whole number from 1 to 65535, got {PORT!r}.') try: addrs = _listen_addrs() except socket.gaierror as exc: - sys.exit(f'Can\'t start: "host" {HOST!r} in config.json doesn\'t resolve ({exc}).') + sys.exit(f'Can\'t start: "host" {HOST!r} in data/config.json doesn\'t resolve ({exc}).') for family, sockaddr in addrs: try: @@ -55,12 +55,12 @@ def check_port(): except OSError as exc: if exc.errno in (errno.EADDRINUSE, getattr(errno, "WSAEADDRINUSE", None)): hint = ("Another program, possibly another WalkingDad window, is using it. " - 'Close it, or set a different "port" in config.json and restart.') + 'Close it, or set a different "port" in data/config.json and restart.') elif exc.errno in (errno.EACCES, getattr(errno, "WSAEACCES", None)): hint = ("The OS doesn't allow this port (on Linux, ports below 1024 need root; " - 'on Windows, it may be reserved). Set a different "port" in config.json and restart.') + 'on Windows, it may be reserved). Set a different "port" in data/config.json and restart.') else: - hint = 'Check "host" and "port" in config.json, then restart.' + hint = 'Check "host" and "port" in data/config.json, then restart.' sys.exit(f"Can't start: can't listen on {sockaddr[0]} port {PORT} ({exc}).\n{hint}") @@ -86,7 +86,7 @@ def http_shutdown(): time.sleep(6) -if __name__ == "__main__": +def main(): check_port() print("Starting production server with Waitress...") @@ -100,11 +100,10 @@ def http_shutdown(): server_process = subprocess.Popen( # --threads: each open SSE connection (/stats_stream) holds a worker - # thread for its entire lifetime, unlike the old short-lived polling - # requests. Defaults well above the Waitress default of 4 (see + # thread for its entire lifetime. Defaults well above the Waitress default of 4 (see # config.py's waitress_threads) so several concurrent devices can # each hold a stream open alongside action POSTs (start/pause/speed) - # without stalling; tunable via config.json like every other setting. + # without stalling; tunable via data/config.json like every other setting. ["waitress-serve", f"--host={HOST}", f"--port={PORT}", f"--threads={WAITRESS_THREADS}", "app:app"], **popen_kwargs, ) @@ -129,3 +128,7 @@ def http_shutdown(): server_process.kill() server_process.wait() print("Server stopped.") + + +if __name__ == "__main__": + main() diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..c01beb2 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "WalkingDad", + "short_name": "WalkingDad", + "start_url": "/", + "display": "standalone", + "background_color": "#f8fafc", + "theme_color": "#f8fafc", + "icons": [ + { "src": "/static/apple-touch-icon.png", "sizes": "436x467", "type": "image/png" } + ] +} diff --git a/storage.py b/storage.py index edd250a..bb091c4 100644 --- a/storage.py +++ b/storage.py @@ -258,12 +258,16 @@ def clear_history(profile=None): conn.close() -def mark_health_logged(session_id): +# sessions.health_logged values. Rows set to 1 before "dismissed" existed may have been dismissed. +HEALTH_PENDING, HEALTH_LOGGED, HEALTH_DISMISSED = 0, 1, 2 + + +def set_health_status(session_id, status): with _write_lock: conn = _connect(_db_path) try: with conn: - conn.execute("UPDATE sessions SET health_logged = 1 WHERE id = ?", (session_id,)) + conn.execute("UPDATE sessions SET health_logged = ? WHERE id = ?", (status, session_id)) finally: conn.close() @@ -296,8 +300,9 @@ def parse(time_str): ) -def migrate_json(json_path): - """One-time import of legacy session_history.json. No-op once meta.migrated_from_json is set.""" +def migrate_json(json_path, backup_dir=None): + """One-time import of legacy session_history.json. No-op once meta.migrated_from_json is set. + The .bak- copy and the renamed .migrated original go in backup_dir (default: alongside).""" if not os.path.exists(json_path): return conn = _connect(_db_path) @@ -318,8 +323,9 @@ def migrate_json(json_path): logging.error(f"Cannot migrate {json_path}, leaving it untouched: {exc}") return + backup_base = os.path.join(backup_dir or os.path.dirname(json_path), os.path.basename(json_path)) stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - shutil.copy2(json_path, f"{json_path}.bak-{stamp}") + shutil.copy2(json_path, f"{backup_base}.bak-{stamp}") # Legacy records have no offset; assume the machine's current one (tz_assumed=1). tz = datetime.now().astimezone().tzinfo @@ -350,7 +356,7 @@ def migrate_json(json_path): conn.close() try: - os.replace(json_path, f"{json_path}.migrated") + os.replace(json_path, f"{backup_base}.migrated") except OSError as exc: logging.warning(f"Migrated {json_path} but could not rename it: {exc}") logging.info(f"Migrated {len(rows)} sessions from {json_path}, skipped {len(records) - len(rows)}") diff --git a/templates/active_session.html b/templates/active_session.html index 05bb93d..6b0a3ef 100644 --- a/templates/active_session.html +++ b/templates/active_session.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block content %} -
+

Active Session

Speed @@ -40,28 +40,30 @@

Active Session

-
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
@@ -72,7 +74,16 @@

Active Session

// buttons need to stay disabled/dim until belt_transitioning clears, not // just during the on-submit window. var actionButtons = document.querySelectorAll('button[type="submit"]'); - disableButtonsOnSubmit('transitioning-hint', actionButtons); + disableButtonsOnSubmit(actionButtons); + + // Native only: needs a secure context (localhost/HTTPS), so a phone on LAN HTTP skips it. + if ('wakeLock' in navigator) { + var requestWakeLock = function() { navigator.wakeLock.request('screen').catch(function() {}); }; + requestWakeLock(); + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'visible') requestWakeLock(); + }); + } startStatsStream(function(data) { setTransitioning('transitioning-hint', actionButtons, data.belt_transitioning === true); diff --git a/templates/base.html b/templates/base.html index e29e51c..83963da 100644 --- a/templates/base.html +++ b/templates/base.html @@ -2,6 +2,7 @@ +