From 9fce47bc21262c48f3e515c7b3eef3217f84323a Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Thu, 8 Oct 2026 14:38:18 -0400 Subject: [PATCH 1/8] docs(agents): follow common UI conventions and WCAG 2.2 AA Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 ++ CHANGELOG.md | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ecbe8ae..c803ffc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,3 +14,5 @@ The maintainer runs macOS (primary) and Fedora Linux, never Windows. Suggest man ## 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..b2c98a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to WalkingDad will be documented in this file. +## [Unreleased] + +### 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. + +--- + ## [1.9.0] (2026-10-08) ### Added From dfec5c019227fa933ec03b8aadb73e5fe7000892 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Thu, 8 Oct 2026 15:22:57 -0400 Subject: [PATCH 2/8] feat(ui): keyboard shortcuts, live tab title, and bounded dependencies (ROADMAP 2.6, 3.3, 3.13) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++++ README.md | 3 ++ ROADMAP.md | 59 +++++++++++++++++++++-------------- app.py | 22 +++++++++++-- config.json.example | 1 + config.py | 2 ++ requirements.txt | 8 ++--- templates/active_session.html | 14 +++++---- templates/base.html | 55 +++++++++++++++++++++++++++++++- templates/paused_session.html | 2 +- templates/settings.html | 10 ++++++ templates/start_session.html | 3 +- tests/conftest.py | 2 +- tests/test_app_helpers.py | 1 + tests/test_routes.py | 29 +++++++++++++++++ 15 files changed, 180 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c98a1..23e0f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to WalkingDad will be documented in this file. ## [Unreleased] +### Added + +- **Keyboard shortcuts** (ROADMAP 3.3). `Space` starts, 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. End Session has 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. + ### 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. diff --git a/README.md b/README.md index 97abc9f..40af620 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` start/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. @@ -136,6 +138,7 @@ Every setting except `database_path` can be changed from the **Settings page** ( | `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) | +| `keyboard_shortcuts_enabled` | `true` | Single-key shortcuts (`?` lists them) | | `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..28bcdf8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,15 @@ 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. + +--- + ## Security & Code Quality ### ✅ 2.1 External Configuration File @@ -197,12 +206,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 dependency in `requirements.txt` 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 +322,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/start_session.html`, `templates/settings.html`, `README.md`, `tests/` + +`Space` starts, pauses, or resumes; `↑`/`W` and `↓`/`S` step the speed; `L`, `K`, and `M` pick the Slow, Moderate, and Max presets; `?` lists them all. End Session has no key, so a stray press can't 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) plus 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. Held keys don't repeat, deliberately unlike e.g. YouTube's volume keys: each press is a locked Bluetooth command | +| **No Start During Restore** | `Space` isn't bound to Start while a crash-restore prompt is showing, since `/start` silently drops the pending restore | +| **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 +347,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 +439,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/base.html`, `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). Rendered server-side on load, then updated from the existing `/stats_stream` handler. Pause and End load a new page, which restores the plain title. --- diff --git a/app.py b/app.py index 67b89b9..01a2c08 100644 --- a/app.py +++ b/app.py @@ -25,6 +25,7 @@ request, url_for, ) +from markupsafe import Markup from ph4_walkingpad.pad import Controller, WalkingPad import config @@ -586,6 +587,19 @@ def _health_log_url(session_record: dict, base_url: str) -> str: return _build_log_shortcut_url(session_record, success_url) +_KEY_LABELS = {"ArrowUp": "\u2191", "ArrowDown": "\u2193"} + + +def hotkey(keys: str) -> Markup: + """Button attributes binding `keys` (aria-keyshortcuts syntax), 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).""" + if not config.KEYBOARD_SHORTCUTS_ENABLED: + return Markup("") + label = " / ".join(_KEY_LABELS.get(k, k) for k in keys.split()) + return Markup(' aria-keyshortcuts="{}" title="Shortcut: {}"').format(keys, label) + + # ── Context processor so templates always know flags ──────────────────── @app.context_processor def inject_flags(): @@ -593,6 +607,8 @@ 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, } @@ -1732,10 +1748,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 +1761,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 diff --git a/config.json.example b/config.json.example index 726d91a..c9e9782 100644 --- a/config.json.example +++ b/config.json.example @@ -13,5 +13,6 @@ "waitress_threads": 16, "apple_health_shortcut_name": "Log WalkingDad Workout", "apple_health_export_enabled": false, + "keyboard_shortcuts_enabled": true, "database_path": "walkingdad.db" } diff --git a/config.py b/config.py index f114f66..94c05da 100644 --- a/config.py +++ b/config.py @@ -17,6 +17,7 @@ "waitress_threads": 16, "apple_health_shortcut_name": "Log WalkingDad Workout", "apple_health_export_enabled": False, + "keyboard_shortcuts_enabled": True, "database_path": "walkingdad.db", } @@ -57,6 +58,7 @@ def _get(key): WAITRESS_THREADS: int = _get("waitress_threads") APPLE_HEALTH_SHORTCUT_NAME: str = _get("apple_health_shortcut_name") APPLE_HEALTH_EXPORT_ENABLED: bool = _get("apple_health_export_enabled") +KEYBOARD_SHORTCUTS_ENABLED: bool = _get("keyboard_shortcuts_enabled") DATABASE_PATH: str = _get("database_path") diff --git a/requirements.txt b/requirements.txt index c0b227e..70c276a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -bleak -flask -ph4-walkingpad -waitress \ No newline at end of file +bleak>=3.0.2,<4 +flask>=3.1.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..bca51f0 100644 --- a/templates/active_session.html +++ b/templates/active_session.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% block title %}{{ "%.1f"|format(speed) }} mph · {{ "%.2f"|format(distance) }} mi - WalkingDad{% endblock %} {% block content %}

Active Session

@@ -6,13 +7,13 @@

Active Session

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

Active Session

- +
- +
- +
-
@@ -96,6 +97,7 @@

Active Session

setStatValue(document.getElementById('distance'), data.distance.toFixed(2) + ' mi'); setStatValue(document.getElementById('steps'), String(parseInt(data.steps, 10))); setStatValue(document.getElementById('calories'), data.calories.toFixed(0)); + document.title = data.speed.toFixed(1) + ' mph \u00b7 ' + data.distance.toFixed(2) + ' mi - WalkingDad'; } }); diff --git a/templates/base.html b/templates/base.html index 83963da..5706fa9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -108,7 +108,7 @@ // logic below also clears the stale localStorage key on next init. })(); - WalkingDad + {% block title %}WalkingDad{% endblock %} @@ -1974,5 +1974,58 @@

-- Active/Paused/Start's own page headings are } })(); + {% if keyboard_shortcuts_enabled %} + + + {% endif %} diff --git a/templates/paused_session.html b/templates/paused_session.html index cbc4aa1..8e3f9bb 100644 --- a/templates/paused_session.html +++ b/templates/paused_session.html @@ -32,7 +32,7 @@

Session Paused

-
diff --git a/templates/settings.html b/templates/settings.html index 8814dce..70969a8 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -71,6 +71,16 @@

Session

+
+

Keyboard

+
+ + +
keyboard_shortcuts_enabled -- press ? on any screen for the list
+
+
+

Server

Changes to Host, Port, and Waitress Threads require restarting the app to take effect.

diff --git a/templates/start_session.html b/templates/start_session.html index 7070738..73d2b96 100644 --- a/templates/start_session.html +++ b/templates/start_session.html @@ -119,7 +119,8 @@ Ready
- diff --git a/tests/conftest.py b/tests/conftest.py index eb06fb6..72c35e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,7 @@ "_shutting_down": False, "_server_stopping": False, "_last_health_status_change": None, } # /settings POST rewrites these on both modules; registering them restores them after each test. -_SETTINGS_CONSTS = [c for _, c, _, _ in app._SETTINGS_SCHEMA] + ["APPLE_HEALTH_EXPORT_ENABLED"] +_SETTINGS_CONSTS = [c for _, c, _, _ in app._SETTINGS_SCHEMA] + ["APPLE_HEALTH_EXPORT_ENABLED", "KEYBOARD_SHORTCUTS_ENABLED"] @pytest.fixture diff --git a/tests/test_app_helpers.py b/tests/test_app_helpers.py index 8ef3c45..7b72178 100644 --- a/tests/test_app_helpers.py +++ b/tests/test_app_helpers.py @@ -162,6 +162,7 @@ def test_inject_flags(app_state, monkeypatch): "connected": False, "connecting": True, "connection_failed": True, "apple_health_shortcut_name": "My Shortcut", "setup_shortcut_url": app._build_setup_shortcut_url(), + "keyboard_shortcuts_enabled": True, "hotkey": app.hotkey, } diff --git a/tests/test_routes.py b/tests/test_routes.py index 2d9de43..3e13930 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -113,6 +113,31 @@ def test_root_pending_restore(client, monkeypatch): assert b"1,234 steps" in data +def test_active_shortcuts_and_title(client, running, monkeypatch): + monkeypatch.setattr(app, "current_speed_kmh", 5.0) + html = client.get("/").get_data(as_text=True) + for keys in ("ArrowUp W", "ArrowDown S", "L", "K", "M", "Space"): + assert f'aria-keyshortcuts="{keys}"' in html + assert 'title="Shortcut: \u2191 / W"' in html + assert 'id="shortcuts-modal"' in html + assert "3.1 mph · 0.00 mi - WalkingDad" in html + + +def test_shortcuts_disabled(client, running, monkeypatch): + monkeypatch.setattr(config, "KEYBOARD_SHORTCUTS_ENABLED", False) + html = client.get("/").get_data(as_text=True) + assert "aria-keyshortcuts" not in html + assert "shortcuts-modal" not in html + + +@pytest.mark.parametrize(("restore", "start_key"), [(False, True), (True, False)]) +def test_start_shortcut_withheld_during_restore(client, monkeypatch, restore, start_key): + if restore: + monkeypatch.setattr(app, "_pending_restore", pending_state()) + html = client.get("/").get_data(as_text=True) + assert ('id="btn-start" aria-keyshortcuts="Space"' in html) is start_key + + def test_start_not_connected(client, app_state, monkeypatch): monkeypatch.setattr(app, "connected", False) assert redirects_root(client.post("/start")) @@ -460,6 +485,10 @@ def test_settings_post(client, monkeypatch): assert config.PORT == written["port"] assert config.APPLE_HEALTH_EXPORT_ENABLED is False assert app.APPLE_HEALTH_EXPORT_ENABLED is False + assert written["keyboard_shortcuts_enabled"] is False + assert config.KEYBOARD_SHORTCUTS_ENABLED is False + client.post("/settings", data={"keyboard_shortcuts_enabled": "on"}) + assert config.KEYBOARD_SHORTCUTS_ENABLED is True @pytest.mark.parametrize(("days_ago", "dismissed"), [(0, False), (2, True)]) From 5432842d62f9a7fdb9d9768113aa4459b933c2b1 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Thu, 8 Oct 2026 15:58:22 -0400 Subject: [PATCH 3/8] fix(ui): drop Start shortcut, swallow keys for disabled buttons, per-screen shortcut help (ROADMAP 3.3) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- ROADMAP.md | 10 +++---- requirements.txt | 1 + templates/base.html | 55 +++++++++++++++++++++++++----------- templates/start_session.html | 3 +- tests/test_routes.py | 8 ++---- 7 files changed, 50 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e0f75..2a065c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to WalkingDad will be documented in this file. ### Added -- **Keyboard shortcuts** (ROADMAP 3.3). `Space` starts, 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. End Session has no shortcut. Turn them off with the **Enable keyboard shortcuts** switch on the Settings page (`keyboard_shortcuts_enabled`). +- **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 diff --git a/README.md b/README.md index 40af620..a7088fc 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ 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` start/pause/resume, `↑`/`W` and `↓`/`S` speed, `L`/`K`/`M` presets; press `?` for the list. Can be turned off in Settings +- **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-`). diff --git a/ROADMAP.md b/ROADMAP.md index 28bcdf8..bccb81d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,7 +211,7 @@ A `pytest` suite covering every module without a treadmill. How to run it: the R **Priority:** Medium **Files Modified:** `requirements.txt` -Each dependency in `requirements.txt` 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. +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. --- @@ -325,15 +325,15 @@ Shipping this surfaced a real gap it needed to close first: a dead BLE connectio ### ✅ 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/start_session.html`, `templates/settings.html`, `README.md`, `tests/` +**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` starts, pauses, or resumes; `↑`/`W` and `↓`/`S` step the speed; `L`, `K`, and `M` pick the Slow, Moderate, and Max presets; `?` lists them all. End Session has no key, so a stray press can't end a walk. +`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) plus 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. Held keys don't repeat, deliberately unlike e.g. YouTube's volume keys: each press is a locked Bluetooth command | -| **No Start During Restore** | `Space` isn't bound to Start while a crash-restore prompt is showing, since `/start` silently drops the pending restore | +| **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** | `?` lists only the shortcuts bound on the current screen | | **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 | --- diff --git a/requirements.txt b/requirements.txt index 70c276a..b04a2ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ 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/base.html b/templates/base.html index 5706fa9..4c4e620 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1983,18 +1983,18 @@
@@ -2004,25 +2004,46 @@ // goes through the same form submit, double-tap guard, and origin check. (function() { var helpModal = new bootstrap.Modal(document.getElementById('shortcuts-modal')); + function bound(key) { + return document.querySelector('button[aria-keyshortcuts~="' + CSS.escape(key) + '"]'); + } + function showHelp() { + var any = false; + document.querySelectorAll('#shortcuts-modal tr[data-keys]').forEach(function(row) { + var on = row.dataset.keys.split(' ').some(bound); + row.hidden = !on; + any = any || on; + }); + document.getElementById('shortcuts-none').hidden = any; + helpModal.show(); + } + + // Space on a button or link focused from the keyboard keeps its native + // meaning, but not after a mouse click (which focuses buttons in + // Chrome/Firefox, not Safari). Tracked by hand: Chrome's :focus-visible + // also starts matching a mouse-focused button once any key is pressed. + var pointerDown = false, keyboardFocused = null; + document.addEventListener('pointerdown', function() { pointerDown = true; keyboardFocused = null; }, true); + document.addEventListener('focusin', function(e) { keyboardFocused = pointerDown ? null : e.target; pointerDown = false; }); + document.addEventListener('keydown', function(e) { + pointerDown = false; // Held keys don't repeat: each press is a locked BLE command. if (e.defaultPrevented || e.repeat || e.ctrlKey || e.metaKey || e.altKey) return; if (e.target.closest('input, select, textarea, [contenteditable]')) return; - // Space on a keyboard-focused button or link keeps its native - // meaning. Mouse clicks focus buttons in Chrome/Firefox but not - // Safari; :focus-visible matches only keyboard focus, in all three. - if (e.key === ' ' && e.target.matches(':focus-visible') && e.target.closest('button, a, summary, [role="button"]')) return; + if (e.key === ' ' && e.target === keyboardFocused && e.target.closest('button, a, summary, [role="button"]')) return; if (document.querySelector('.modal.show, .color-theme-popover.show, .app-header.menu-open')) return; if (e.key === '?') { e.preventDefault(); - helpModal.show(); + showHelp(); return; } - var key = e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key); - var btn = document.querySelector('button[aria-keyshortcuts~="' + CSS.escape(key) + '"]'); - if (!btn || btn.disabled) return; + var btn = bound(e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key)); + if (!btn) return; + // Swallowed even while disabled (belt transitioning), so Space and + // the arrows don't fall through to scrolling the page. e.preventDefault(); - btn.click(); + if (!btn.disabled) btn.click(); }); })(); diff --git a/templates/start_session.html b/templates/start_session.html index 73d2b96..7070738 100644 --- a/templates/start_session.html +++ b/templates/start_session.html @@ -119,8 +119,7 @@ Ready
- {# No shortcut while a restore is pending: /start silently drops it. #} - diff --git a/tests/test_routes.py b/tests/test_routes.py index 3e13930..1adb0ef 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -130,12 +130,10 @@ def test_shortcuts_disabled(client, running, monkeypatch): assert "shortcuts-modal" not in html -@pytest.mark.parametrize(("restore", "start_key"), [(False, True), (True, False)]) -def test_start_shortcut_withheld_during_restore(client, monkeypatch, restore, start_key): - if restore: - monkeypatch.setattr(app, "_pending_restore", pending_state()) +def test_start_has_no_shortcut(client): html = client.get("/").get_data(as_text=True) - assert ('id="btn-start" aria-keyshortcuts="Space"' in html) is start_key + assert 'aria-keyshortcuts="' not in html + assert 'id="shortcuts-modal"' in html def test_start_not_connected(client, app_state, monkeypatch): From baae627f8b244e0771ab9e1e4b5e8313b21fe45a Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Thu, 8 Oct 2026 17:12:56 -0400 Subject: [PATCH 4/8] fix(ui): harden shortcuts and belt sequences from review (ROADMAP 3.3, 3.13, 3.18) Shortcuts: - Derive tooltips and the ? list in JS from aria-keyshortcuts; hotkey() now emits only that attribute - Track keyboard vs mouse focus via capture-phase keydown/pointerdown instead of :focus-visible, so Space presses a keyboard-focused control but not one focused by a mouse click or script - Restore focus when the ? list closes (WCAG 2.4.3); block keys while any modal is opening; only block for the phone menu while it's visible - Create the help modal lazily so shortcuts survive a Bootstrap CDN failure Belt sequences: - Set belt_transitioning in the start/pause/resume/end routes, guarded by a generation counter so only the newest sequence can clear it; reset it when a new BLE loop starts - Register sequences and speed changes before cancelling their predecessor, so they can't run concurrently or escape a disconnect - _cancel_task: let the caller's own cancellation propagate, and cancel tasks from another event loop on that loop - End Session stops the belt when it cancels an unfinished sequence - Ignore speed changes while a belt sequence is in flight, checked and queued under the session lock - Round, not truncate, km/h to device units (3.8 + 0.6 set 4.3) Pages: - Render buttons disabled while transitioning, re-enabling after 15 s if no stats update arrives; reload on back/forward-cache restore - Single tab-title format, reset by the shutdown overlay, which also stops stats handling (connecting page included) Docs: CHANGELOG, ROADMAP 2.6/3.3/3.13/3.18, new 1.8 (C2 behavior after the physical remote, dropped back-to-back commands, app_speed target). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ README.md | 2 +- ROADMAP.md | 22 +++- app.py | 193 +++++++++++++++++++++++----------- config.json.example | 2 +- config.py | 4 +- templates/active_session.html | 14 ++- templates/base.html | 100 ++++++++++++------ templates/connecting.html | 5 + templates/paused_session.html | 1 + templates/start_session.html | 1 + tests/conftest.py | 2 + tests/test_app_helpers.py | 2 +- tests/test_ble_async.py | 119 ++++++++++++++++++++- tests/test_routes.py | 68 +++++++++++- 15 files changed, 426 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a065c5..c5ed970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ All notable changes to WalkingDad will be documented in this file. - **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 or Resume.** 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. +- **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. diff --git a/README.md b/README.md index a7088fc..5c0c0bc 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,8 @@ 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) | -| `apple_health_export_enabled` | `false` | Shows the Log to Apple Health prompt after each session; see [Apple Health Export](#apple-health-export) | | `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 bccb81d..d0ab545 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,18 @@ A session left paused (manual, auto, Bluetooth drop, or restored after a crash) --- +### 1.8 Commands Ignored After the Physical Remote, and Speed Steps During a Ramp +- **Status:** Planned +- **Priority:** High +- **Problem:** Seen on a C2 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. + +--- + ## Security & Code Quality ### ✅ 2.1 External Configuration File @@ -331,9 +343,9 @@ Shipping this surfaced a real gap it needed to close first: a dead BLE connectio | Feature | Description | |---|---| -| **Clicks the Button** | `hotkey()` in `app.py` tags each button with the native `aria-keyshortcuts` attribute (also read by screen readers) plus 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 | +| **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** | `?` lists only the shortcuts bound on the current screen | +| **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 | --- @@ -442,9 +454,9 @@ The Active screen requests a native screen wake lock (`navigator.wakeLock`) and ### ✅ 3.13 Live Stats in Browser Tab Title **Status:** ✅ Complete **Priority:** Low -**Files Modified:** `templates/base.html`, `templates/active_session.html` +**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). Rendered server-side on load, then updated from the existing `/stats_stream` handler. Pause and End load a new page, which restores the plain title. +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. --- @@ -490,7 +502,7 @@ While walking, the tab reads `3.2 mph · 1.40 mi - WalkingDad`: live content fir **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 01a2c08..8a539e0 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import asyncio import atexit +import contextlib import csv import io import json @@ -91,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 @@ -587,17 +592,14 @@ def _health_log_url(session_record: dict, base_url: str) -> str: return _build_log_shortcut_url(session_record, success_url) -_KEY_LABELS = {"ArrowUp": "\u2191", "ArrowDown": "\u2193"} - - def hotkey(keys: str) -> Markup: - """Button attributes binding `keys` (aria-keyshortcuts syntax), 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).""" + """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("") - label = " / ".join(_KEY_LABELS.get(k, k) for k in keys.split()) - return Markup(' aria-keyshortcuts="{}" title="Shortcut: {}"').format(keys, label) + return Markup(' aria-keyshortcuts="{}"').format(keys) # ── Context processor so templates always know flags ──────────────────── @@ -609,6 +611,7 @@ def inject_flags(): "setup_shortcut_url": _build_setup_shortcut_url(), "keyboard_shortcuts_enabled": config.KEYBOARD_SHORTCUTS_ENABLED, "hotkey": hotkey, + "belt_transitioning": _belt_transitioning, } @@ -1055,19 +1058,27 @@ 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}) async def _full_wake_sequence(): @@ -1149,7 +1160,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) @@ -1171,9 +1182,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, @@ -1196,6 +1209,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. @@ -1377,8 +1393,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), @@ -1387,9 +1404,9 @@ 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: + try: + task.get_loop().call_soon_threadsafe(task.cancel) + except RuntimeError: # loop closed task.cancel() if will_auto_reconnect: @@ -1517,6 +1534,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.""" @@ -1534,17 +1583,20 @@ 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: + if (was_running or interrupted) and controller: try: await _run_locked( lambda: asyncio.wait_for(controller.stop_belt(), timeout=_BLE_WRITE_TIMEOUT_SECONDS), @@ -1558,12 +1610,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() @@ -1887,13 +1944,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: @@ -1913,8 +1971,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 @@ -1924,7 +1983,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 @@ -1953,13 +2012,14 @@ 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 _run_locked( lambda: asyncio.wait_for(controller.stop_belt(), timeout=_BLE_WRITE_TIMEOUT_SECONDS), @@ -1976,11 +2036,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}") @@ -2010,15 +2071,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 @@ -2037,15 +2099,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 @@ -2063,14 +2126,20 @@ 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 a Pause/End can't land between + # the check and the queueing, leaving this step to follow its 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 c9e9782..945b4d6 100644 --- a/config.json.example +++ b/config.json.example @@ -11,8 +11,8 @@ "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, - "keyboard_shortcuts_enabled": true, "database_path": "walkingdad.db" } diff --git a/config.py b/config.py index 94c05da..24ee1d5 100644 --- a/config.py +++ b/config.py @@ -15,9 +15,9 @@ "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, - "keyboard_shortcuts_enabled": True, "database_path": "walkingdad.db", } @@ -56,9 +56,9 @@ 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") -KEYBOARD_SHORTCUTS_ENABLED: bool = _get("keyboard_shortcuts_enabled") DATABASE_PATH: str = _get("database_path") diff --git a/templates/active_session.html b/templates/active_session.html index bca51f0..451f31e 100644 --- a/templates/active_session.html +++ b/templates/active_session.html @@ -1,5 +1,4 @@ {% extends "base.html" %} -{% block title %}{{ "%.1f"|format(speed) }} mph · {{ "%.2f"|format(distance) }} mi - WalkingDad{% endblock %} {% block content %}

Active Session

@@ -76,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) { @@ -86,6 +86,12 @@

Active Session

}); } + var speedEl = document.getElementById('speed'), distanceEl = document.getElementById('distance'); + function updateTitle() { + document.title = speedEl.textContent + ' mph \u00b7 ' + distanceEl.textContent + ' - WalkingDad'; + } + updateTitle(); + startStatsStream(function(data) { setTransitioning('transitioning-hint', actionButtons, data.belt_transitioning === true); // Check if the server has auto-paused the session @@ -93,11 +99,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)); - document.title = data.speed.toFixed(1) + ' mph \u00b7 ' + data.distance.toFixed(2) + ' mi - WalkingDad'; + updateTitle(); } }); diff --git a/templates/base.html b/templates/base.html index 4c4e620..868daf2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -108,7 +108,7 @@ // logic below also clears the stale localStorage key on next init. })(); - {% block title %}WalkingDad{% endblock %} + WalkingDad @@ -1504,13 +1504,20 @@

-- Active/Paused/Start's own page headings are // call this synchronously the moment their own inline