diff --git a/AGENTS.md b/AGENTS.md index ecbe8ae..332d4e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,5 +12,10 @@ Keep all docs (ROADMAP.md, CHANGELOG.md `[Unreleased]`, README.md, docs/) in syn ## 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. +## Testing +When adding or changing a test, run the suite on every Python version CI runs (the matrices in `.github/workflows/ci.yml` and `.gitlab-ci.yml` are the source of truth), not just the local interpreter; e.g. `uv venv --python 3.10 `. asyncio timing and cancellation differ between versions. + ## 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. + +Follow common UI conventions (what major web apps do) and [WCAG 2.2](https://www.w3.org/TR/WCAG22/) level AA wherever practical. When a design knowingly deviates from either, say so in the design and why. diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f3ca0..502a7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to WalkingDad will be documented in this file. +## [Unreleased] + +### Added + +- **Keyboard shortcuts** (ROADMAP 3.3). `Space` pauses or resumes; `↑`/`W` and `↓`/`S` change speed; `L`, `K`, and `M` pick the Slow, Moderate, and Max presets. Press `?` for the list, or hover a button to see its key. Start and End Session have no shortcut. Turn them off with the **Enable keyboard shortcuts** switch on the Settings page (`keyboard_shortcuts_enabled`). +- **The browser tab shows your speed and distance while walking** (ROADMAP 3.13), e.g. `3.2 mph · 1.40 mi - WalkingDad`. + +### Changed + +- **Dependency versions are bounded** (ROADMAP 2.6). `requirements.txt` now sets a tested minimum and blocks the next major version of each package, so a fresh install can't pull in a breaking release. + +### Fixed + +- **A quick second press can no longer undo Start, Pause, Resume, or End.** The next screen used to load with its buttons enabled for about a second, so a double-tap on Pause could resume the belt while it was still stopping. Buttons now stay disabled until the belt finishes, and a page brought back with the browser's Back or Forward button reloads instead of showing stale, enabled buttons. +- **End Session right after Pause always stops the belt.** Ending a session (e.g. from a second device) while a Pause was still reaching the treadmill could cancel the Pause before it stopped the belt, leaving it running. +- **Speed changes no longer land on top of a Start, Resume, Pause, or End.** A speed button pressed while the belt was still starting or resuming (e.g. from a second device) could take effect right after it, from a near-zero reading; it's now ignored until the belt is moving. A speed change still on its way when you pause or end could reach the treadmill after it stopped; Pause and End now cancel it first. +- **Speed buttons and Resume no longer land 0.1 km/h short.** A rounding error made some steps fall short, e.g. 3.8 + 0.6 km/h set 4.3 instead of 4.4. + +### Internal + +- **`AGENTS.md` asks agents to follow common UI conventions and WCAG 2.2 AA** wherever practical, and to call out deliberate deviations in their designs. +- **`AGENTS.md` asks agents to run new or changed tests on every Python version CI uses**, after a test passed locally on 3.12 but failed in CI on 3.10. + +--- + ## [1.9.0] (2026-10-08) ### Added diff --git a/README.md b/README.md index 97abc9f..5c0c0bc 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ The official WalkingPad experience is a bloated mobile app that wants your email - **Real-time stats**: Speed, distance, steps, calories, and active time, updated live - **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 +- **Keyboard shortcuts**: `Space` pause/resume, `↑`/`W` and `↓`/`S` speed, `L`/`K`/`M` presets; press `?` for the list. Can be turned off in Settings +- **Live tab title**: While walking, the browser tab shows speed and distance - **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 (`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. @@ -135,6 +137,7 @@ Every setting except `database_path` can be changed from the **Settings page** ( | `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) | +| `keyboard_shortcuts_enabled` | `true` | Single-key shortcuts (`?` lists them) | | `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 `data/`) | diff --git a/ROADMAP.md b/ROADMAP.md index db2a9e7..2481349 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,36 @@ A session left paused (manual, auto, Bluetooth drop, or restored after a crash) --- +### 1.7 Bluetooth Disconnect Callback Never Registered +- **Status:** Planned +- **Priority:** High +- **Problem:** `_connect_to_pad()` registers `_handle_disconnect` via `set_disconn_callback()` or `set_disconnected_callback()`, but `BleakClient` in Bleak 3.x has neither; it takes the callback only as a constructor argument, and `ph4-walkingpad`'s `Controller.connect()` builds the client without one. So a drop is only noticed by the staleness watchdogs (~15 s active, ~45 s idle/paused) instead of immediately. The tests' fake controller has `set_disconn_callback`, which hides this. +- **Solution:** Pass `disconnected_callback=_handle_disconnect` when the `BleakClient` is built, by overriding `Controller.connect()`, and drop the dead old-API branches. +- **Implementation:** Its own `bugfix/` branch, with a test against the real `BleakClient` signature rather than the fake. + +--- + +### 1.8 Commands Ignored After the Physical Remote, and Speed Steps During a Ramp +- **Status:** Planned +- **Priority:** High +- **Problem:** Seen on a C2 (firmware 6.1.2, software 6.3.0) in a status-packet log: + - After a press on the physical remote, status byte 16 changed from 3 to 2, and a Pause's `stop_belt()` (speed 0) had no effect until Resume's `start_belt()` about 4 s later. Resume's light-wake probe then took the slowing belt for a running one, and the session auto-paused. + - A `change_speed()` sent ~0.3 s after another was silently not applied. + - `+`/`-` step from the reported speed, which lags the target while the belt ramps (Max from 2.2 km/h took ~7 s). The status packet's `app_speed` byte carries the app-set target immediately, in km/h × 10 (`ph4-walkingpad`'s `/30` is wrong for the C2); the remote doesn't update it. +- **Solution:** Reproduce each on the treadmill, then: find what byte 16 means and what makes app commands take effect again after the remote; have the light-wake probe require a speed that isn't falling; space or coalesce back-to-back speed commands; step from `app_speed` while the belt ramps toward it. +- **Implementation:** Its own `bugfix/` branch, checked on the treadmill, not just against the fake controller. + +--- + +### 1.9 Firmware Version Check and Update +- **Status:** Planned (investigation) +- **Priority:** Low +- **Problem:** Updating the pad's firmware takes KingSmith's KS Fit app and an account. The pad reports its versions over the standard Device Information service (a C2 here: firmware 6.1.2, software 6.3.0, Beken BK-BLE-1.0 module) and exposes TI-style over-the-air update characteristics (`f000ffc1`/`f000ffc2`, "Img Identify"/"Img Block"), so WalkingDad could check and update without the app. Firmware behavior also affects 1.8. +- **Solution:** Replicate KS Fit's check and update. On a button press (opt-in, nothing automatic), ask KingSmith's server for the latest firmware for the connected model and compare it with the pad's versions; if newer, download the image from KingSmith and flash it. No firmware is bundled or redistributed. +- **Implementation:** Spike first: decompile KS Fit 6.6.0 (`com.kingsmith.xiaojin`, base APK with jadx; the arm64 split with Ghidra if the logic is native) to find the version endpoint and whether it needs an account, the image format (signed or encrypted), and the update procedure (handshake, block size, acks, retries, verification). Build only the check unless the update procedure proves safe. A failed flash could brick the controller, recoverable at best over a serial connection. + +--- + ## Security & Code Quality ### ✅ 2.1 External Configuration File @@ -197,12 +227,12 @@ A `pytest` suite covering every module without a treadmill. How to run it: the R --- -### 2.6 Pin Dependency Versions -- **Status:** Planned -- **Priority:** Medium -- **Problem:** `requirements.txt` lists `bleak`, `flask`, `ph4-walkingpad`, and `waitress` with no version constraints. A fresh `pip install` can silently pull a breaking major version with no warning. -- **Solution:** Pin each dependency to a known-working version (exact `==` or a floor `>=` plus a documented upper bound), tested against the versions currently in use. -- **Implementation:** Capture current working versions from an active `venv` (`pip freeze`), add them to `requirements.txt`, note the tested versions in the README. +### ✅ 2.6 Pin Dependency Versions +**Status:** ✅ Complete +**Priority:** Medium +**Files Modified:** `requirements.txt` + +Each direct dependency in `requirements.txt` (including `markupsafe`, imported by `app.py`) has a floor at its tested version and a cap below the next major (e.g. `bleak>=3.0.2,<4`), so patch releases still arrive but a breaking major can't. `requirements.txt` is the one record of the tested versions. --- @@ -313,18 +343,19 @@ Shipping this surfaced a real gap it needed to close first: a dead BLE connectio --- -### 3.3 Keyboard Shortcuts -- **Status:** Planned -- **Priority:** Medium -- **Problem:** Adjusting speed or pausing requires using a mouse/touch, which is inconvenient while walking. -- **Solution:** Add keyboard shortcuts for core actions: - - `Arrow Up` / `W`: Increase speed - - `Arrow Down` / `S`: Decrease speed - - `Space`: Pause / Resume - - `M`: Max speed - - `L`: Slow preset - - `K`: Moderate preset -- **Implementation:** Add a keyboard event listener in the active session template that sends `fetch()` requests to the corresponding routes. +### ✅ 3.3 Keyboard Shortcuts +**Status:** ✅ Complete +**Priority:** Medium +**Files Modified:** `app.py`, `config.py`, `config.json.example`, `templates/base.html`, `templates/active_session.html`, `templates/paused_session.html`, `templates/settings.html`, `requirements.txt`, `README.md`, `tests/` + +`Space` pauses or resumes; `↑`/`W` and `↓`/`S` step the speed; `L`, `K`, and `M` pick the Slow, Moderate, and Max presets; `?` lists them all. Start and End Session have no key, so a stray press can't start the belt with nobody on it or end a walk. + +| Feature | Description | +|---|---| +| **Clicks the Button** | `hotkey()` in `app.py` tags each button with the native `aria-keyshortcuts` attribute (also read by screen readers), from which `base.html` adds a `title` tooltip; one listener in `base.html` clicks the matching enabled button, so a shortcut takes the same form-submit path, double-tap guard, and cross-site check as a click | +| **Stays Out of the Way** | Ignored while typing in a field, with Ctrl/Alt/Cmd held, while a dialog, the color-theme popover, or the phone menu is open, and for `Space` on a button or link focused from the keyboard (Tab), not by a mouse click. A key bound to a disabled button (belt transitioning) is still swallowed, so `Space` doesn't scroll the page. Held keys don't repeat, deliberately unlike e.g. YouTube's volume keys: each press is a locked Bluetooth command | +| **Help Fits the Screen** | `?` builds its list from the current screen's tagged buttons, so it shows only what works there and can't drift from the real bindings. Only `?` needs Bootstrap; the shortcuts keep working if its CDN fails to load | +| **Can Be Turned Off** | `keyboard_shortcuts_enabled` (Settings page, default on), as WCAG 2.1.4 requires for single-character shortcuts. Off removes the attributes and the listener entirely | --- @@ -337,6 +368,7 @@ Shipping this surfaced a real gap it needed to close first: a dead BLE connectio - 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 + - Cover the live tab title (3.13) too, which shows mph like the page --- @@ -428,12 +460,12 @@ The Active screen requests a native screen wake lock (`navigator.wakeLock`) and --- -### 3.13 Live Stats in Browser Tab Title -- **Status:** Planned -- **Priority:** Low -- **Problem:** If the WalkingDad tab isn't focused during a session, checking progress means switching back to it. -- **Solution:** Update `document.title` with a compact live readout (e.g. "3.2 mph · 1.4 mi") while a session is active, reverting to the normal title on pause/end. -- **Implementation:** Update `document.title` from the existing `/stats_stream` SSE handler already driving the on-page numbers; no new endpoint needed. +### ✅ 3.13 Live Stats in Browser Tab Title +**Status:** ✅ Complete +**Priority:** Low +**Files Modified:** `templates/active_session.html` + +While walking, the tab reads `3.2 mph · 1.40 mi - WalkingDad`: live content first, app name last, the convention most sites follow (tabs truncate from the right). Built from the page's own speed and distance readouts, on load and on each `/stats_stream` tick, so there's one format to change. Pause and End load a new page, which restores the plain title. --- @@ -479,7 +511,7 @@ The Active screen requests a native screen wake lock (`navigator.wakeLock`) and **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. +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 (a back/forward-cache restore reloads the page instead, since its belt state is stale), closing a double-tap window. The routes also set `belt_transitioning` before scheduling the sequence, so the next screen renders its buttons already disabled; if no stats update ever arrives, they re-enable after 15 s rather than staying dead. 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. --- diff --git a/app.py b/app.py index 67b89b9..c57ad99 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import asyncio import atexit +import contextlib import csv import io import json @@ -25,6 +26,7 @@ request, url_for, ) +from markupsafe import Markup from ph4_walkingpad.pad import Controller, WalkingPad import config @@ -90,6 +92,10 @@ def format_seconds_to_hms(total_seconds: int) -> str: _idle_watchdog_task: asyncio.Task | None = None # Track the paused/idle connection watchdog _belt_sequence_task: asyncio.Task | None = None # Track an in-flight start/resume/pause belt sequence _belt_transitioning = False # True while a belt sequence is in flight; exposed in /stats for UI +# Bumped per queued sequence; only the newest may clear _belt_transitioning, so a +# superseded or cancelled one can't re-enable the UI under a newer one. +_belt_transition_gen = 0 +_belt_transition_lock = threading.Lock() _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(config.DATA_DIR, "session_history.json") # legacy; migrated into the DB at startup @@ -586,6 +592,16 @@ def _health_log_url(session_record: dict, base_url: str) -> str: return _build_log_shortcut_url(session_record, success_url) +def hotkey(keys: str) -> Markup: + """The aria-keyshortcuts attribute binding `keys`, or nothing when shortcuts + are off, so screen readers aren't told about dead keys (WCAG 2.1.4 requires + single-character shortcuts be switchable off). base.html derives the + tooltip and the `?` list from it.""" + if not config.KEYBOARD_SHORTCUTS_ENABLED: + return Markup("") + return Markup(' aria-keyshortcuts="{}"').format(keys) + + # ── Context processor so templates always know flags ──────────────────── @app.context_processor def inject_flags(): @@ -593,6 +609,9 @@ def inject_flags(): "connected": connected, "connecting": connecting, "connection_failed": connection_failed, "apple_health_shortcut_name": APPLE_HEALTH_SHORTCUT_NAME, "setup_shortcut_url": _build_setup_shortcut_url(), + "keyboard_shortcuts_enabled": config.KEYBOARD_SHORTCUTS_ENABLED, + "hotkey": hotkey, + "belt_transitioning": _belt_transitioning, } @@ -1039,19 +1058,29 @@ async def _stats_monitor(): async def _cancel_task(task: asyncio.Task | None) -> None: - """Cancel a task if it's still running and wait for it to fully unwind. + """Cancel a task if it's still running and wait for it to finish. 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 (the global gets overwritten next - time a new task is created). + time a new task is created). Waits for `task` only, not for a task that + `task` was itself cancelling; _ble_command_lock and the belt-transition + generation keep any such stragglers' side effects in order. """ if task and not task.done(): + if task.get_loop() is not asyncio.get_running_loop(): + # Left over from a previous connection's loop, which is tearing it + # down; cancel it there and don't wait across threads. + with contextlib.suppress(RuntimeError): # that loop already closed + task.get_loop().call_soon_threadsafe(task.cancel) + return task.cancel() - try: - await task - except asyncio.CancelledError: - pass + # wait(), not `await task`: cancelling the caller mid-await would be + # forwarded to `task`, which (like every belt sequence) may swallow it, + # letting the caller run on as if never cancelled. + await asyncio.wait({task}) + if not task.cancelled() and task.exception(): + logging.warning(f"Task ended with an error while being cancelled: {task.exception()!r}") async def _full_wake_sequence(): @@ -1133,7 +1162,7 @@ async def _body(): if target_speed_kmh is not None: logging.info(f"Setting speed to {target_speed_kmh:.1f} km/h.") - await asyncio.wait_for(controller.change_speed(int(target_speed_kmh * 10)), timeout=_BLE_WRITE_TIMEOUT_SECONDS) + await asyncio.wait_for(controller.change_speed(round(target_speed_kmh * 10)), timeout=_BLE_WRITE_TIMEOUT_SECONDS) await asyncio.sleep(0.5) await _run_locked(_body, timeout=_BLE_SEQUENCE_LOCK_TIMEOUT_SECONDS) @@ -1155,9 +1184,11 @@ async def _locked_change_speed(dev_speed: int): the paragraph above assumes. """ global _speed_change_task - await _cancel_task(_speed_change_task) - _speed_change_task = asyncio.current_task() + # Registered before waiting on the predecessor, so a disconnect meanwhile + # cancels this one too (the belt sequences do the same). + prev, _speed_change_task = _speed_change_task, asyncio.current_task() try: + await _cancel_task(prev) await _run_locked( lambda: asyncio.wait_for(controller.change_speed(dev_speed), timeout=_BLE_WRITE_TIMEOUT_SECONDS), timeout=_BLE_SEQUENCE_LOCK_TIMEOUT_SECONDS, @@ -1180,6 +1211,9 @@ def _ble_thread(): connection_failed = True return + # Reset before publishing the loop, so a route that begins a transition + # on the new loop can't have its flag cleared under its running sequence. + _end_belt_transition() # anything queued on the previous loop died with it ble_loop = loop # Fresh lock per connection attempt -- see the module-level comment on # _ble_command_lock for why this can't just be created once at import time. @@ -1361,8 +1395,9 @@ def _handle_disconnect(client): # task's own event loop -- routed through call_soon_threadsafe() so this # is correct regardless of which thread actually called _handle_disconnect # (per the docstring above, that's not guaranteed to be ble_loop's own - # thread). Falls back to a direct call only if ble_loop is already gone, - # in which case there's no loop left to schedule onto anyway. + # thread), and onto the task's own loop, which after a manual reconnect + # may be the previous connection's. Falls back to a direct call only if + # that loop is already closed, leaving nothing to schedule onto anyway. for name, task in ( ("stats monitor", _stats_monitor_task), ("idle watchdog", _idle_watchdog_task), @@ -1371,10 +1406,11 @@ def _handle_disconnect(client): ): if task and not task.done() and task is not current: logging.info(f"Cancelling {name} due to disconnect") - if ble_loop and not ble_loop.is_closed(): - ble_loop.call_soon_threadsafe(task.cancel) - else: - task.cancel() + try: + task.get_loop().call_soon_threadsafe(task.cancel) + except RuntimeError: # loop closed + with contextlib.suppress(RuntimeError): # cancel() can't schedule on it either + task.cancel() if will_auto_reconnect: try: @@ -1501,6 +1537,38 @@ def root(): # ── End Session ──────────────────────────────────────────────────────── +def _begin_belt_transition() -> int: + """Mark the belt transitioning from the route, not when the sequence first runs + on ble_loop, so the page the route redirects to already renders its buttons + disabled. Returns the generation the sequence passes to _end_belt_transition().""" + global _belt_transitioning, _belt_transition_gen + with _belt_transition_lock: + _belt_transition_gen += 1 + _belt_transitioning = True + return _belt_transition_gen + + +def _end_belt_transition(gen: int | None = None): + """Clear the flag if `gen` is still the newest sequence; None clears it + unconditionally (a new BLE loop: nothing from the old one is in flight).""" + global _belt_transitioning, _belt_transition_gen + with _belt_transition_lock: + if gen is None: + _belt_transition_gen += 1 + elif gen != _belt_transition_gen: + return + _belt_transitioning = False + + +def _queue_belt_sequence(coro, gen: int): + """Schedule a belt sequence; on failure clear its transition and re-raise.""" + try: + asyncio.run_coroutine_threadsafe(coro, ble_loop) + except Exception: + _end_belt_transition(gen) + raise + + def _end_session(stale: bool = False): """Save the session to history, reset counters. stale=True (auto-end of a long pause) re-checks under the lock so a Resume racing the timer wins.""" @@ -1518,17 +1586,21 @@ def _end_session(stale: bool = False): # Without cancelling, an in-flight resume sequence could keep running # after end_session returns, recreating a monitor for a dead session. 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), - # 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 + global _belt_sequence_task + # Registers itself as _belt_sequence_task like its three siblings + # (start/pause/resume), so an in-flight End Session can be observed + # and cancelled the same way as the others. Registering before + # awaiting the predecessor's cancellation means a sequence queued + # meanwhile cancels this one instead of running alongside it. + prev, _belt_sequence_task = _belt_sequence_task, asyncio.current_task() + # A Pause cancelled here may not have sent its stop_belt yet (and a + # Start/Resume may have started the belt), so stop it ourselves. + interrupted = prev is not None and not prev.done() try: + await _cancel_task(prev) await _cancel_task(_stats_monitor_task) - if was_running and controller: + await _cancel_task(_speed_change_task) # else a queued step could follow stop_belt + if (was_running or interrupted) and controller: try: await _run_locked( lambda: asyncio.wait_for(controller.stop_belt(), timeout=_BLE_WRITE_TIMEOUT_SECONDS), @@ -1542,12 +1614,17 @@ async def _end_belt_sequence(): logging.error(f"Error stopping belt on end_session: {exc}") _handle_disconnect(None) finally: - _belt_sequence_task = None - _belt_transitioning = False + if _belt_sequence_task is asyncio.current_task(): + _belt_sequence_task = None + _end_belt_transition(gen) # Guarded so a dead loop can't skip the history save below. if ble_loop and not ble_loop.is_closed(): - asyncio.run_coroutine_threadsafe(_end_belt_sequence(), ble_loop) + gen = _begin_belt_transition() + try: + _queue_belt_sequence(_end_belt_sequence(), gen) + except Exception as exc: # the loop closed after the check above + logging.error(f"Failed to queue end sequence: {exc}") # Save session to history _save_session() @@ -1732,10 +1809,11 @@ def settings_page(): # Unlike every field above, a checkbox is absent from form data # entirely when unchecked rather than submitting a falsy value, so # the generic default-to-current-value loop above can't handle it -- - # this is the one boolean setting, handled as a one-off rather than - # complicating _SETTINGS_SCHEMA's cast-function contract for it. + # the two boolean settings are handled as one-offs rather than + # complicating _SETTINGS_SCHEMA's cast-function contract for them. was_enabled = APPLE_HEALTH_EXPORT_ENABLED updates["apple_health_export_enabled"] = "apple_health_export_enabled" in request.form + updates["keyboard_shortcuts_enabled"] = "keyboard_shortcuts_enabled" in request.form _write_config(updates) for form_key, const_name, _cast, live in _SETTINGS_SCHEMA: @@ -1744,6 +1822,7 @@ def settings_page(): globals()[const_name] = updates[form_key] config.APPLE_HEALTH_EXPORT_ENABLED = updates["apple_health_export_enabled"] APPLE_HEALTH_EXPORT_ENABLED = updates["apple_health_export_enabled"] + config.KEYBOARD_SHORTCUTS_ENABLED = updates["keyboard_shortcuts_enabled"] # Turning the feature on shouldn't retroactively surface a session # that predates it being enabled (pending_health_export only checks @@ -1869,13 +1948,14 @@ def start_session(): _pending_restore = None # A fresh session supersedes any unresolved restore prompt _save_session_state() + gen = _begin_belt_transition() + async def _start_belt_sequence(): - global belt_running, _stats_monitor_task, _belt_sequence_task, _belt_transitioning + global belt_running, _stats_monitor_task, _belt_sequence_task global _resume_grace_deadline - await _cancel_task(_belt_sequence_task) - _belt_sequence_task = asyncio.current_task() - _belt_transitioning = True + prev, _belt_sequence_task = _belt_sequence_task, asyncio.current_task() try: + await _cancel_task(prev) logging.info("Starting belt...") await _cancel_task(_stats_monitor_task) # Always the full STANDBY/MANUAL toggle here, never light-wake: @@ -1895,8 +1975,9 @@ async def _start_belt_sequence(): belt_running = False _handle_disconnect(None) finally: - _belt_sequence_task = None - _belt_transitioning = False + if _belt_sequence_task is asyncio.current_task(): + _belt_sequence_task = None + _end_belt_transition(gen) # Re-stamped here, not just once when the button was clicked: # _wake_and_start_belt() can legitimately run long on a slow # connection, which would otherwise eat into (or exhaust) the @@ -1906,7 +1987,7 @@ async def _start_belt_sequence(): _resume_grace_deadline = time.time() + RESUME_GRACE_PERIOD_SECONDS try: - asyncio.run_coroutine_threadsafe(_start_belt_sequence(), ble_loop) + _queue_belt_sequence(_start_belt_sequence(), gen) except Exception as exc: logging.error(f"Failed to queue start sequence: {exc}") belt_running = False @@ -1935,14 +2016,16 @@ def pause_session(): # Single ordered sequence on ble_loop: cancel monitor before stop_belt() # so an immediate resume can't interleave with the monitor's in-flight polls. + gen = _begin_belt_transition() + async def _pause_belt_sequence(): - global _belt_sequence_task, _belt_transitioning + global _belt_sequence_task # Cancel any prior belt sequence before self-registering. - await _cancel_task(_belt_sequence_task) - _belt_sequence_task = asyncio.current_task() - _belt_transitioning = True + prev, _belt_sequence_task = _belt_sequence_task, asyncio.current_task() try: + await _cancel_task(prev) await _cancel_task(_stats_monitor_task) + await _cancel_task(_speed_change_task) # else a queued step could follow stop_belt await _run_locked( lambda: asyncio.wait_for(controller.stop_belt(), timeout=_BLE_WRITE_TIMEOUT_SECONDS), timeout=_BLE_SEQUENCE_LOCK_TIMEOUT_SECONDS, @@ -1958,11 +2041,12 @@ async def _pause_belt_sequence(): logging.error(f"Error stopping belt on pause: {exc}") _handle_disconnect(None) finally: - _belt_sequence_task = None - _belt_transitioning = False + if _belt_sequence_task is asyncio.current_task(): + _belt_sequence_task = None + _end_belt_transition(gen) try: - asyncio.run_coroutine_threadsafe(_pause_belt_sequence(), ble_loop) + _queue_belt_sequence(_pause_belt_sequence(), gen) except Exception as exc: logging.error(f"Failed to queue pause sequence: {exc}") @@ -1992,15 +2076,16 @@ def resume_session(): _record_resume() _save_session_state() + gen = _begin_belt_transition() + async def _resume_belt_sequence(): - global belt_running, _stats_monitor_task, _belt_sequence_task, _belt_transitioning + global belt_running, _stats_monitor_task, _belt_sequence_task global _resume_grace_deadline # Cancel any prior in-flight sequence (e.g. pause) before sending # commands; concurrent coroutines on ble_loop interleave BLE writes. - await _cancel_task(_belt_sequence_task) - _belt_sequence_task = asyncio.current_task() - _belt_transitioning = True + prev, _belt_sequence_task = _belt_sequence_task, asyncio.current_task() try: + await _cancel_task(prev) logging.info("Attempting resume: Sending wake-up and start sequence to device...") await _cancel_task(_stats_monitor_task) # try_light_wake=True: Resume typically follows our own recent @@ -2019,15 +2104,16 @@ async def _resume_belt_sequence(): belt_running = False _handle_disconnect(None) finally: - _belt_sequence_task = None - _belt_transitioning = False + if _belt_sequence_task is asyncio.current_task(): + _belt_sequence_task = None + _end_belt_transition(gen) # See _start_belt_sequence()'s matching comment: re-stamped here # so a slow wake-up sequence can't eat into the post-completion # ramp-up protection this deadline is meant to provide. _resume_grace_deadline = time.time() + RESUME_GRACE_PERIOD_SECONDS try: - asyncio.run_coroutine_threadsafe(_resume_belt_sequence(), ble_loop) + _queue_belt_sequence(_resume_belt_sequence(), gen) except Exception as exc: logging.error(f"Failed to queue resume sequence: {exc}") belt_running = False @@ -2045,14 +2131,21 @@ def decrease_speed(): def _set_preset_speed(speed_kmh: float): """Shared body for the fixed-speed presets (min/slow/max) below.""" - if not belt_running: - return redirect(url_for("root")) + # Mid start/resume the reported speed is ~0 and the sequence sets its own + # speed; a step queued now would land right after it (the UI's buttons are + # disabled then too, but not on a second device). + # Under the lock the belt routes hold, so this can't interleave with a + # Pause/End route's own check-and-queue; those sequences then cancel any + # step still pending before sending stop_belt. + with _session_state_lock: + if not belt_running or _belt_transitioning: + return redirect(url_for("root")) - dev_speed = int(speed_kmh * 10) - 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}") + dev_speed = round(speed_kmh * 10) # not int(): float sums like (3.8 + 0.6) * 10 land at 43.99... + 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")) diff --git a/config.json.example b/config.json.example index 726d91a..945b4d6 100644 --- a/config.json.example +++ b/config.json.example @@ -11,6 +11,7 @@ "host": "0.0.0.0", "port": 5001, "waitress_threads": 16, + "keyboard_shortcuts_enabled": true, "apple_health_shortcut_name": "Log WalkingDad Workout", "apple_health_export_enabled": false, "database_path": "walkingdad.db" diff --git a/config.py b/config.py index f114f66..24ee1d5 100644 --- a/config.py +++ b/config.py @@ -15,6 +15,7 @@ "host": "0.0.0.0", "port": 5001, "waitress_threads": 16, + "keyboard_shortcuts_enabled": True, "apple_health_shortcut_name": "Log WalkingDad Workout", "apple_health_export_enabled": False, "database_path": "walkingdad.db", @@ -55,6 +56,7 @@ def _get(key): HOST: str = _get("host") PORT: int = _get("port") WAITRESS_THREADS: int = _get("waitress_threads") +KEYBOARD_SHORTCUTS_ENABLED: bool = _get("keyboard_shortcuts_enabled") 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") diff --git a/requirements.txt b/requirements.txt index c0b227e..b04a2ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -bleak -flask -ph4-walkingpad -waitress \ No newline at end of file +bleak>=3.0.2,<4 +flask>=3.1.3,<4 +markupsafe>=3.0.3,<4 +ph4-walkingpad>=1.0.2,<2 +waitress>=3.0.2,<4 diff --git a/templates/active_session.html b/templates/active_session.html index 6b0a3ef..a2a694c 100644 --- a/templates/active_session.html +++ b/templates/active_session.html @@ -6,13 +6,13 @@

Active Session

Speed
- +
{{ "%.1f"|format(speed) }}mph
- +

Active Session

- +
- +
- +
-
@@ -75,6 +75,7 @@

Active Session

// just during the on-submit window. var actionButtons = document.querySelectorAll('button[type="submit"]'); disableButtonsOnSubmit(actionButtons); + initTransitioning('transitioning-hint', actionButtons, {{ belt_transitioning|tojson }}); // Native only: needs a secure context (localhost/HTTPS), so a phone on LAN HTTP skips it. if ('wakeLock' in navigator) { @@ -85,6 +86,13 @@

Active Session

}); } + var speedEl = document.getElementById('speed'), distanceEl = document.getElementById('distance'); + function updateTitle() { + var title = speedEl.textContent + ' mph \u00b7 ' + distanceEl.textContent + ' - WalkingDad'; + if (document.title !== title) document.title = title; + } + updateTitle(); + startStatsStream(function(data) { setTransitioning('transitioning-hint', actionButtons, data.belt_transitioning === true); // Check if the server has auto-paused the session @@ -92,10 +100,11 @@

Active Session

window.location.reload(); } else { setStatValue(document.getElementById('time_active'), data.time_active); - setStatValue(document.getElementById('speed'), data.speed.toFixed(1)); - setStatValue(document.getElementById('distance'), data.distance.toFixed(2) + ' mi'); + setStatValue(speedEl, data.speed.toFixed(1)); + setStatValue(distanceEl, data.distance.toFixed(2) + ' mi'); setStatValue(document.getElementById('steps'), String(parseInt(data.steps, 10))); setStatValue(document.getElementById('calories'), data.calories.toFixed(0)); + updateTitle(); } }); diff --git a/templates/base.html b/templates/base.html index 83963da..868daf2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1504,13 +1504,20 @@

-- Active/Paused/Start's own page headings are // call this synchronously the moment their own inline + {% if keyboard_shortcuts_enabled %} + + + {% endif %} diff --git a/templates/connecting.html b/templates/connecting.html index 5178105..bb2781c 100644 --- a/templates/connecting.html +++ b/templates/connecting.html @@ -22,6 +22,11 @@

var es = new EventSource("{{ url_for('stats_stream') }}"); es.onmessage = function(event) { var data = JSON.parse(event.data); + if (shutdownShown || data.stopping === true) { + if (!shutdownShown) showShutdownOverlay(); + es.close(); + return; + } if (data.is_connected === true || data.connection_failed === true) { window.location.reload(); } diff --git a/templates/paused_session.html b/templates/paused_session.html index cbc4aa1..0bfe501 100644 --- a/templates/paused_session.html +++ b/templates/paused_session.html @@ -32,7 +32,7 @@

Session Paused

-
@@ -48,6 +48,7 @@

Session Paused