-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathapp.py
More file actions
449 lines (351 loc) · 16.5 KB
/
Copy pathapp.py
File metadata and controls
449 lines (351 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
import asyncio
import logging
import os
import threading
import webbrowser
from threading import Timer
import time
from collections import deque
from bleak import BleakScanner
from flask import Flask, render_template, redirect, url_for, jsonify, make_response, request
from ph4_walkingpad.pad import Controller, WalkingPad
# ── Logging Setup ────────────────────────────────────────────────────────
# All print() statements will be replaced with this logging configuration.
# It provides timed, leveled output. Set level=logging.DEBUG to see verbose messages.
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
)
# ── Conversion constants ─────────────────────────────────────────────────
KM_TO_MI = 0.621371
KMH_TO_MPH = 0.621371
KCAL_PER_MILE = 95 # rough kcal per mile
# Speed control constants
MAX_SPEED_KMH = 6.0 # Approx 3.7 mph, a common max for these pads
MIN_SPEED_KMH = 1.0
SPEED_STEP = 0.6 # Speed change per button press in km/h
SLOW_WALK_SPEED_KMH = 4.5 # Approx 2.8 MPH
def kcal_estimate(miles: float) -> float:
return KCAL_PER_MILE * miles
# In app.py
def format_seconds_to_hms(total_seconds):
"""Converts total seconds to H:MM:SS string format."""
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
return f"{hours}:{minutes:02}:{seconds:02}"
# ── Flask & global state ────────────────────────────────────────────────
app = Flask(__name__)
connected = connecting = connection_failed = False
ble_loop: asyncio.AbstractEventLoop | None = None
controller: Controller | None = None
_pad_address: str | None = None
_auto_pause_grace_until = 0
speed_history = deque(maxlen=15)
session_active = belt_running = False
resume_speed_kmh = 2.0 # default if none yet
current_speed_kmh = current_distance_km = 0.0
current_steps = 0
current_calories = 0.0
current_session_active_seconds = 0
_last_dev_dist = _last_dev_steps = 0
# ── Context processor so templates always know flags ────────────────────
@app.context_processor
def inject_flags():
return dict(connected=connected, connecting=connecting, connection_failed=connection_failed)
# ── BLE helpers ─────────────────────────────────────────────────────────
async def _connect_to_pad() -> bool:
global controller, _pad_address
dev = None
if _pad_address:
logging.info(f"Attempting to connect to known address: {_pad_address}")
try:
dev = await BleakScanner.find_device_by_address(_pad_address, timeout=5)
except Exception as exc:
logging.warning(f"Failed to find device by address: {exc}")
dev = None
if not dev:
logging.info("Scanning for device by name 'WalkingPad'...")
try:
dev = await BleakScanner.find_device_by_name("WalkingPad", timeout=10)
except Exception as exc:
logging.warning(f"Failed to find device by name: {exc}")
dev = None
if not dev:
logging.error("Could not find WalkingPad. Ensure it is on and in range.")
_pad_address = None
return False
_pad_address = dev.address
logging.info(f"Device found! Address: {_pad_address}")
controller = Controller()
await controller.run(dev.address)
if hasattr(controller, "client") and controller.client:
controller.client.set_disconnected_callback(_handle_disconnect)
await controller.switch_mode(WalkingPad.MODE_MANUAL)
def _status_cb(_sender, st):
try:
if isinstance(st, dict):
dist = st.get("dist", 0)
steps = st.get("steps", 0)
speed = st.get("speed", 0)
else:
dist = getattr(st, "dist", 0)
steps = getattr(st, "steps", 0)
speed = getattr(st, "speed", 0)
process_status_packet(dist, steps, speed)
logging.debug(f"Push d={dist} s={steps} v={speed}")
except Exception as exc:
logging.warning(f"status_cb error: {exc}")
controller.on_cur_status_received = _status_cb
if hasattr(controller, "enable_notifications"):
try:
await controller.enable_notifications()
except Exception as exc:
logging.warning(f"enable_notifications failed: {exc}")
return True
def process_status_packet(dev_dist, dev_steps, dev_speed):
"""Update cumulative stats from raw values AND handle auto-pause."""
global belt_running, resume_speed_kmh, _auto_pause_grace_until
global current_speed_kmh, current_distance_km, current_steps, current_calories
global _last_dev_dist, _last_dev_steps
new_reported_speed_kmh = dev_speed / 10.0
# Continuously populate the speed history with stable, non-zero speeds.
if belt_running and new_reported_speed_kmh > MIN_SPEED_KMH:
speed_history.append(new_reported_speed_kmh)
# AUTO-PAUSE LOGIC
if time.time() > _auto_pause_grace_until:
if belt_running and new_reported_speed_kmh == 0 and current_speed_kmh > 0:
logging.info("Belt has stopped unexpectedly. Auto-pausing session.")
# Use the OLDEST speed from history to ignore the deceleration phase.
if speed_history:
resume_speed_kmh = speed_history[0] # Use the first (oldest) item
else:
# Fallback if pause happens too quickly after starting
resume_speed_kmh = MIN_SPEED_KMH
belt_running = False
# CUMULATIVE STATS LOGIC (is unchanged)
# ...
if dev_dist < _last_dev_dist:
_last_dev_dist = 0
current_distance_km += (dev_dist - _last_dev_dist) / 100.0
_last_dev_dist = dev_dist
if dev_steps < _last_dev_steps:
_last_dev_steps = 0
current_steps += dev_steps - _last_dev_steps
_last_dev_steps = dev_steps
current_speed_kmh = new_reported_speed_kmh
current_calories = kcal_estimate(current_distance_km * KM_TO_MI)
async def _stats_monitor():
"""Active monitor: explicitly request a status packet every second."""
global current_session_active_seconds
logging.info("Stats monitor started")
while belt_running:
if belt_running: # Double check, as belt_running can change between await calls
current_session_active_seconds += 1
try:
status = await controller.ask_stats()
if status:
if isinstance(status, dict):
dist = status.get("dist", 0)
steps = status.get("steps", 0)
speed = status.get("speed", 0)
else:
dist = getattr(status, "dist", 0)
steps = getattr(status, "steps", 0)
speed = getattr(status, "speed", 0)
process_status_packet(dist, steps, speed)
logging.debug(f"Poll {status}")
except Exception as exc:
logging.warning(f"ask_stats error: {exc}")
await asyncio.sleep(1)
def _ble_thread():
global connected, connecting, connection_failed, ble_loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
ble_loop = loop
if not loop.run_until_complete(_connect_to_pad()):
connecting = False
connection_failed = True
return
connected = True
connecting = False
try:
loop.run_forever()
finally:
connected = False
loop.close()
def _start_ble_thread():
global connecting, connection_failed
if connected or connecting:
return
connecting = True
connection_failed = False
threading.Thread(target=_ble_thread, daemon=True).start()
def _handle_disconnect(client):
"""Callback function to handle unexpected disconnections."""
global connected, belt_running, connecting, connection_failed
if connected: # Only log if we thought we were connected
logging.warning("Device has disconnected unexpectedly.")
connected = False
belt_running = False
connecting = False
connection_failed = True
# ── Flask routes ────────────────────────────────────────────────────────
@app.route("/")
def root():
if not connected:
return render_template("connecting.html") #
time_active_display = "0:00:00" # Default for start/paused if not running
if session_active: # Only calculate if a session is or was active
time_active_display = format_seconds_to_hms(current_session_active_seconds)
if not session_active:
# For start_session, always show 0 time initially
return render_template("start_session.html", time_active="0:00:00")
template = "active_session.html" if belt_running else "paused_session.html"
return render_template(
template,
speed=current_speed_kmh * KMH_TO_MPH,
distance=current_distance_km * KM_TO_MI,
steps=current_steps,
calories=current_calories,
time_active=time_active_display
)
@app.route("/reconnect", endpoint="reconnect")
@app.route("/manual_reconnect", endpoint="manual_reconnect")
def reconnect():
if not connected and not connecting:
_start_ble_thread()
return redirect(url_for("root"))
@app.route("/start")
def start_session():
"""Begin a new session: reset counters, start belt, launch stats monitor."""
global session_active, belt_running, current_distance_km, current_steps, current_calories, resume_speed_kmh
global current_session_active_seconds
if not connected:
return redirect(url_for("root"))
current_distance_km = current_steps = current_calories = 0.0
current_session_active_seconds = 0
resume_speed_kmh = 2.0
speed_history.clear()
session_active = True
belt_running = True
async def seq():
try:
await controller.start_belt()
await asyncio.sleep(0.5)
asyncio.create_task(_stats_monitor())
except Exception as exc:
logging.error(f"Start sequence error: {exc}")
asyncio.run_coroutine_threadsafe(seq(), ble_loop)
return redirect(url_for("root"))
# ── Pause / Resume ───────────────────────────────────────────────────────
@app.route("/pause", endpoint="pause")
@app.route("/pause_session", endpoint="pause_session")
def pause_session():
global belt_running, resume_speed_kmh
if not belt_running:
return redirect(url_for("root"))
# Use the most recent speed from our history for manual pause
if speed_history:
resume_speed_kmh = speed_history[-1]
belt_running = False
asyncio.run_coroutine_threadsafe(controller.stop_belt(), ble_loop)
return redirect(url_for("root"))
@app.route("/resume", endpoint="resume")
@app.route("/resume_session", endpoint="resume_session")
def resume_session():
global belt_running, _auto_pause_grace_until, session_active # session_active ensures we only resume active sessions
if not session_active: # Can't resume if no session was active
logging.warning("Resume called but no active session.")
return redirect(url_for("root"))
if belt_running: # Already running, do nothing
logging.info("Resume called but belt is already running.")
return redirect(url_for("root"))
# --- CRITICAL FIX: Optimistically set state for UI and grace period ---
logging.info("Resume button clicked. Setting app state to active.")
belt_running = True
_auto_pause_grace_until = time.time() + 7 # Generous 7-second grace period for commands to take effect
async def seq():
try:
logging.info("Attempting resume: Sending wake-up and start sequence to device...")
# Standard wake-up and start sequence
await controller.switch_mode(WalkingPad.MODE_STANDBY)
await asyncio.sleep(0.5)
await controller.switch_mode(WalkingPad.MODE_MANUAL)
await asyncio.sleep(0.5)
await controller.start_belt()
await asyncio.sleep(0.5)
logging.info(f"Setting speed to {resume_speed_kmh:.1f} km/h.")
await controller.change_speed(int(resume_speed_kmh * 10))
await asyncio.sleep(0.5) # Allow speed change to propagate
# Start the monitor if it wasn't running or to be sure
asyncio.create_task(_stats_monitor())
logging.info("Resume sequence commands sent, monitor ensured.")
except Exception as exc:
logging.error(f"Error during resume sequence, device may have disconnected: {exc}")
_handle_disconnect(None) # This will set belt_running = False and connected = False
# The frontend polling will then reload to the correct disconnected/connecting page.
asyncio.run_coroutine_threadsafe(seq(), ble_loop)
# The redirect will now happen after belt_running is True in the main thread.
return redirect(url_for("root"))
# ── Speed Controls ───────────────────────────────────────────────────────
@app.route("/decrease_speed")
def decrease_speed():
"""Decrease the belt speed by one step."""
if not belt_running:
return redirect(url_for("root"))
new_speed_kmh = max(MIN_SPEED_KMH, current_speed_kmh - SPEED_STEP)
dev_speed = int(new_speed_kmh * 10)
asyncio.run_coroutine_threadsafe(controller.change_speed(dev_speed), ble_loop)
return redirect(url_for("root"))
@app.route("/slow_speed")
def slow_speed():
"""Set the belt speed to a predefined slow walk speed."""
if not belt_running:
return redirect(url_for("root"))
dev_speed = int(SLOW_WALK_SPEED_KMH * 10)
asyncio.run_coroutine_threadsafe(controller.change_speed(dev_speed), ble_loop)
return redirect(url_for("root"))
@app.route("/increase_speed")
def increase_speed():
"""Increase the belt speed by one step."""
if not belt_running:
return redirect(url_for("root"))
new_speed_kmh = min(MAX_SPEED_KMH, current_speed_kmh + SPEED_STEP)
dev_speed = int(new_speed_kmh * 10)
asyncio.run_coroutine_threadsafe(controller.change_speed(dev_speed), ble_loop)
return redirect(url_for("root"))
@app.route("/max_speed")
def max_speed():
"""Set the belt speed to maximum."""
if not belt_running:
return redirect(url_for("root"))
dev_speed = int(MAX_SPEED_KMH * 10)
asyncio.run_coroutine_threadsafe(controller.change_speed(dev_speed), ble_loop)
return redirect(url_for("root"))
# ── Live JSON endpoint ───────────────────────────────────────────────────
@app.route("/stats", endpoint="get_stats")
def stats_json():
# Calculate formatted_time_active within the function scope
formatted_time_active = format_seconds_to_hms(current_session_active_seconds)
data = dict(
is_connected=connected,
is_running=belt_running,
speed=round(current_speed_kmh * KMH_TO_MPH, 1),
distance=round(current_distance_km * KM_TO_MI, 2),
steps=current_steps,
calories=round(current_calories),
time_active=formatted_time_active
)
resp = make_response(jsonify(data))
resp.headers["Cache-Control"] = "no-store"
return resp
# ── Shutdown endpoint ──────────────────────────────────────────────────
@app.route("/shutdown", methods=['POST'])
def shutdown():
"""Forcefully shut down the Flask application process."""
logging.info("Server shutting down via forceful exit...")
os._exit(0)
# ── Kick off BLE thread ──────────────────────────────────────────────────
# The server is no longer started here. This just pre-starts the BLE thread.
_start_ble_thread()