diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index c712d25..0000000 --- a/.coveragerc +++ /dev/null @@ -1,2 +0,0 @@ -[run] -omit = tests/* diff --git a/.gitignore b/.gitignore index 76c368b..14dc5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ config.json .coverage htmlcov/ coverage.xml + +data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb42e4..1d09950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,21 @@ All notable changes to WalkingDad will be documented in this file. ## [Unreleased] +### 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 @@ -19,6 +32,7 @@ All notable changes to WalkingDad will be documented in this file. - **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. --- diff --git a/README.md b/README.md index 470dc89..97abc9f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ 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 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 @@ -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.** @@ -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 -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 `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 | |---|---|---| @@ -132,7 +137,7 @@ Every setting except `database_path` can be changed from the **Settings page** ( | `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 ded586d..d159eff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -209,7 +209,7 @@ Pull/merge requests and pushes to `main`/`development` run the lint and test sui | **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. `ruff.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) | +| **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) | --- @@ -237,7 +237,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`) | --- @@ -402,12 +402,12 @@ 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 +**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. See `CHANGELOG.md` `[Unreleased]`. --- @@ -470,12 +470,12 @@ Active, Paused, and Start screens redesigned to read like the WalkingPad's own o --- -### 3.19 Phone Layout -- **Status:** Planned -- **Priority:** Medium -- **Problem:** `base.html` has no ``, so iOS Safari renders the desktop layout at ~980px and scales it down: tiny text, small tap targets (±, presets, Pause/End), and most of the screen empty. The existing `@media (max-width: 480px)` rules have never applied on a phone for the same reason. Confirmed on an iPhone over the LAN (2026-10-07). -- **Solution:** Add ``, then tune the 480px block for walking-while-tapping use: tap targets of at least 44px, Pause/End and presets full-width, the stats strip as a 2x2 grid, and the speed readout using the freed vertical space. Desktop layout unchanged. -- **Implementation:** `templates/base.html` (meta tag and the 480px block); check active, paused, start, connecting, and settings screens on a real phone, since the 480px rules are untested. Do before 3.6, since the QR code is what brings phones in. 3.7 (swipe gestures) and 3.17 (tablet breakpoint) build on a working phone baseline. +### ✅ 3.19 Phone Layout +**Status:** ✅ Complete +**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. See `CHANGELOG.md` `[Unreleased]`. --- @@ -539,7 +539,7 @@ Two QR codes, both self-contained (no fetch back to WalkingDad's server): a **se **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. diff --git a/app.py b/app.py index e2751ab..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 @@ -91,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 @@ -395,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) ────────────────── @@ -482,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")) @@ -501,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: @@ -511,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 @@ -526,10 +573,17 @@ 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 ──────────────────── @@ -1409,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: @@ -1425,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" @@ -1561,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 @@ -2012,6 +2099,7 @@ def _build_stats_payload() -> dict: "calories": round(current_calories), "time_active": format_seconds_to_hms(current_session_active_seconds), "stopping": _server_stopping, + "health_status_changed": _last_health_status_change, } 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/ruff.toml b/pyproject.toml similarity index 63% rename from ruff.toml rename to pyproject.toml index 48c2908..6466e49 100644 --- a/ruff.toml +++ b/pyproject.toml @@ -1,4 +1,11 @@ -[lint] +[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/pytest.ini b/pytest.ini deleted file mode 100644 index c7b23ec..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -pythonpath = . -testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt index 3cf67e4..5cc0dc9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +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 ae748ce..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}") @@ -103,7 +103,7 @@ def main(): # 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, ) 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..5dfd4aa 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

-
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
@@ -74,6 +76,15 @@

Active Session

var actionButtons = document.querySelectorAll('button[type="submit"]'); disableButtonsOnSubmit('transitioning-hint', 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); // Check if the server has auto-paused the session diff --git a/templates/base.html b/templates/base.html index 60f7534..efb2503 100644 --- a/templates/base.html +++ b/templates/base.html @@ -2,6 +2,7 @@ +
diff --git a/templates/start_session.html b/templates/start_session.html index e3d05d3..e549a38 100644 --- a/templates/start_session.html +++ b/templates/start_session.html @@ -10,7 +10,7 @@

Start New Session

{{ pending_restore.calories }} cal · {{ pending_restore.time_active }}

-
+
@@ -25,17 +25,24 @@

Start New Session

comes from inject_flags() (config-derived only). Both are built as plain strings in app.py, not with Jinja's tojson/urlencode filters -- mixing those with plain-string concatenation double-escaped & into &. #} -
+

Session complete. Log it to Apple Health?

- + + Log to Apple Health +
+

Haven't installed the Shortcut yet? Install it here

+{% endif %} +{% if health_export_enabled %}