treadmill-cron 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: treadmill-cron
3
+ Version: 0.1.0
@@ -0,0 +1,38 @@
1
+ # treadmill cron
2
+ Vary your walking speed and incline on your treadmill desk.
3
+
4
+ Only works with the nordictrack 6.5s treadmill at the moment. If you can code *a little* you may be able to adapt it ot your treadmill.
5
+
6
+ AI-generated an unreviewed code.
7
+
8
+ ## Motivation
9
+ Treadmill desks are great. You often want to just walk while your treadmill runs rather than messing with settings. I plot along at 1.4 -2.0 kph for hours at an end. Treadmill cron allows you to automate some intervals or variation through your day to get you some free exercise.
10
+
11
+ Intense exercise has certain health benefits including hormonal effects which reduce visceral fat so make a good addition to low intensity exercise.
12
+
13
+ ## Features
14
+ * Increase speed at certain times during the day or every hour
15
+ * Have the speed "creep up" for other times of the day. This is useful if you get tired.
16
+
17
+ ## Installatation
18
+ pipx install nord-ich-track
19
+ pipx install treadmill-cron
20
+
21
+ ## Usage
22
+ Start nord-ich-track in daemon mode.
23
+
24
+ Generate a schedule file then run:
25
+
26
+ ```
27
+ :00-:05 3.0 5.0
28
+ ```
29
+
30
+ This sets the speed to 3.0 and incline 5 for five minutes every hour.
31
+
32
+
33
+ `treadmill-cron schedule`
34
+
35
+
36
+ ## LLM use
37
+ I use an LLM to generate my config file. If you give it access to this source code it can likely do things for you.
38
+
@@ -0,0 +1,13 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "treadmill-cron"
7
+ version = "0.1.0"
8
+
9
+ [tool.setuptools]
10
+ py-modules = ["treadmill_cron"]
11
+
12
+ [project.scripts]
13
+ treadmill-cron = "treadmill_cron:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: treadmill-cron
3
+ Version: 0.1.0
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ treadmill_cron.py
4
+ treadmill_cron.egg-info/PKG-INFO
5
+ treadmill_cron.egg-info/SOURCES.txt
6
+ treadmill_cron.egg-info/dependency_links.txt
7
+ treadmill_cron.egg-info/entry_points.txt
8
+ treadmill_cron.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ treadmill-cron = treadmill_cron:main
@@ -0,0 +1 @@
1
+ treadmill_cron
@@ -0,0 +1,723 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Treadmill interval daemon. Polls treadmill state, fires intervals on schedule.
4
+
5
+ Schedule entry format (in ~/.config/treadmill-cron/schedule):
6
+ [priority=N] TIME_RANGE speed incline [, now-now+MM:SS speed incline]...
7
+
8
+ Time ranges:
9
+ :MM-:MM hourly minute window
10
+ :MM:SS-:MM:SS hourly with seconds
11
+ H:MM-H:MM absolute time of day
12
+ day+MM:SS-day+MM:SS MM:SS of cumulative belt time today (once/day)
13
+
14
+ Ramping (any number can take +delta/day or +delta/week):
15
+ 3.0+0.05/day +0.05 each day since start_date
16
+ 3.5+0.1/week +0.1/7 per day
17
+ day+120:00-day+123:00+30s/day end-time grows by 30s/day
18
+
19
+ Priority:
20
+ No priority -> won't preempt anything; if it would overlap something running, skip.
21
+ priority=N (integer) -> higher wins; preempts a running lower-priority entry.
22
+
23
+ Sequences (continuations after comma):
24
+ Each continuation runs immediately after the previous chunk for an explicit duration.
25
+ The whole sequence shares the priority and runs uninterrupted (modulo preemption).
26
+
27
+ Creep:
28
+ TIME_RANGE creep interval=10m step=0.1 max=2.5
29
+ Gentle upward pressure during free walking. Every `interval` of belt time,
30
+ nudge speed up by `step` until `max`. Climbs from the *measured* speed, so a
31
+ manual slow-down just lowers where the next nudge starts. TIME_RANGE limits it
32
+ to a clock window (use `*` for always). Lowest priority: only acts when no
33
+ other entry is running, so every interval above outranks it for free.
34
+ interval accepts s/m/h (e.g. 10m, 600s).
35
+
36
+ Subcommands:
37
+ treadmill-cron status show effective values for today
38
+ treadmill-cron hold skip the next daily increment
39
+ treadmill-cron reset zero the day counter
40
+ """
41
+ import re
42
+ import subprocess
43
+ import json
44
+ import sys
45
+ import time
46
+ from datetime import datetime, date, timedelta
47
+ from pathlib import Path
48
+
49
+ CONFIG_DIR = Path.home() / '.config' / 'treadmill-cron'
50
+ SCHEDULE_FILE = CONFIG_DIR / 'schedule'
51
+ STATE_FILE = CONFIG_DIR / 'state.json'
52
+ CONFIG_FILE = CONFIG_DIR / 'config.json'
53
+ TICK_SECS = 2.0
54
+
55
+ DEFAULT_CONFIG = {
56
+ 'messager': [],
57
+ 'notify_kinds': ['day'],
58
+ }
59
+
60
+
61
+ def load_config():
62
+ cfg = dict(DEFAULT_CONFIG)
63
+ if CONFIG_FILE.exists():
64
+ cfg.update(json.loads(CONFIG_FILE.read_text()))
65
+ return cfg
66
+
67
+
68
+ def notify(cfg, title, body):
69
+ cmd = cfg.get('messager') or []
70
+ if cmd:
71
+ subprocess.Popen([*cmd, title, body])
72
+ else:
73
+ print(f"MESSAGE: {title}: {body} (set messager)")
74
+
75
+
76
+ def ctl(*args) -> str:
77
+ result = subprocess.run(['nord-ich-track', 'ctl', *args], capture_output=True, text=True)
78
+ return result.stdout.strip()
79
+
80
+
81
+ def get_treadmill_state():
82
+ try:
83
+ return json.loads(ctl('get_state'))
84
+ except (json.JSONDecodeError, ValueError):
85
+ return {}
86
+
87
+
88
+ def is_running(treadmill):
89
+ return treadmill.get('type') != 'no_state' and treadmill.get('speed_kph', 0) > 0
90
+
91
+
92
+ # ---- state file ----
93
+
94
+ def load_state():
95
+ if STATE_FILE.exists():
96
+ return json.loads(STATE_FILE.read_text())
97
+ return {}
98
+
99
+
100
+ def save_state(s):
101
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
102
+ STATE_FILE.write_text(json.dumps(s, indent=2))
103
+
104
+
105
+ def days_elapsed(s):
106
+ """Global day counter (currently used for held_count only)."""
107
+ start = s.get('start_date')
108
+ if not start:
109
+ return 0
110
+ elapsed = (date.today() - date.fromisoformat(start)).days
111
+ return max(0, elapsed - s.get('held_count', 0))
112
+
113
+
114
+ def entry_day(entry, state):
115
+ """Per-entry day count: days since the entry's own start_date, minus global held_count."""
116
+ sd = entry.get('start_date')
117
+ if sd is None:
118
+ return 0
119
+ elapsed = (date.today() - sd).days
120
+ return max(0, elapsed - state.get('held_count', 0))
121
+
122
+
123
+ # ---- ramp parsing ----
124
+
125
+ def parse_ramp_number(s):
126
+ m = re.fullmatch(r'(-?\d+(?:\.\d+)?)(?:\+(-?\d+(?:\.\d+)?)/(day|week))?', s)
127
+ if not m:
128
+ raise ValueError(f"bad number: {s}")
129
+ delta = float(m.group(2) or 0)
130
+ if m.group(3) == 'week':
131
+ delta /= 7
132
+ return float(m.group(1)), delta
133
+
134
+
135
+ def parse_time_delta(num, unit):
136
+ return float(num) * (60 if unit == 'min' else 1)
137
+
138
+
139
+ def parse_mm_ss(s):
140
+ m = re.fullmatch(r'(\d+):(\d{2})', s)
141
+ if not m:
142
+ raise ValueError(f"bad MM:SS: {s}")
143
+ return int(m.group(1)) * 60 + int(m.group(2))
144
+
145
+
146
+ def parse_duration(s):
147
+ """'10m' / '600s' / '1h' / bare seconds -> int seconds."""
148
+ m = re.fullmatch(r'(\d+(?:\.\d+)?)(s|m|min|h)?', s)
149
+ if not m:
150
+ raise ValueError(f"bad duration: {s}")
151
+ unit = {'s': 1, 'm': 60, 'min': 60, 'h': 3600}[m.group(2) or 's']
152
+ return int(float(m.group(1)) * unit)
153
+
154
+
155
+ def creep_window_open(window, now):
156
+ """Is `now` inside the creep entry's clock window? None window = always."""
157
+ if window is None:
158
+ return True
159
+ if window['kind'] == 'hourly':
160
+ cur = now.minute * 60 + now.second
161
+ return window['start_secs'] <= cur < window['end_secs']
162
+ if window['kind'] == 'absolute':
163
+ cur = now.hour * 3600 + now.minute * 60 + now.second
164
+ start = window['start_h'] * 3600 + window['start_m'] * 60
165
+ end = window['end_h'] * 3600 + window['end_m'] * 60
166
+ return start <= cur < end
167
+ return False
168
+
169
+
170
+ # ---- schedule parsing ----
171
+
172
+ def _parse_time_range(time_range):
173
+ m = re.fullmatch(r':(\d{1,2})(?::(\d{2}))?-:(\d{1,2})(?::(\d{2}))?', time_range)
174
+ if m:
175
+ return {
176
+ 'kind': 'hourly',
177
+ 'start_secs': int(m.group(1)) * 60 + int(m.group(2) or 0),
178
+ 'end_secs': int(m.group(3)) * 60 + int(m.group(4) or 0),
179
+ }
180
+ m = re.fullmatch(r'(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})', time_range)
181
+ if m:
182
+ return {
183
+ 'kind': 'absolute',
184
+ 'start_h': int(m.group(1)), 'start_m': int(m.group(2)),
185
+ 'end_h': int(m.group(3)), 'end_m': int(m.group(4)),
186
+ }
187
+ m = re.fullmatch(
188
+ r'day\+(\d+):(\d{2})-day\+(\d+):(\d{2})'
189
+ r'(?:\+(\d+(?:\.\d+)?)(min|s|sec)/day)?',
190
+ time_range,
191
+ )
192
+ if m:
193
+ end_delta = parse_time_delta(m.group(5), m.group(6)) if m.group(5) else 0
194
+ return {
195
+ 'kind': 'day',
196
+ 'start_offset': int(m.group(1)) * 60 + int(m.group(2)),
197
+ 'end_offset': int(m.group(3)) * 60 + int(m.group(4)),
198
+ 'end_delta_per_day': end_delta,
199
+ }
200
+ return None
201
+
202
+
203
+ def _entry_has_ramp(entry):
204
+ if entry['speed_delta'] != 0 or entry['incline_delta'] != 0:
205
+ return True
206
+ if entry.get('end_delta_per_day', 0) != 0:
207
+ return True
208
+ for c in entry.get('continuations', []):
209
+ if c['speed_delta'] != 0 or c['incline_delta'] != 0:
210
+ return True
211
+ return False
212
+
213
+
214
+ def _parse_creep(first_parts):
215
+ """Parse `TIME_RANGE creep interval=.. step=.. max=..` (TIME_RANGE may be `*`)."""
216
+ time_tok = first_parts[0]
217
+ if time_tok in ('*', 'always'):
218
+ window = None
219
+ else:
220
+ window = _parse_time_range(time_tok)
221
+ if not window or window['kind'] == 'day':
222
+ raise ValueError(f"creep time must be a clock window or *, got: {time_tok!r}")
223
+
224
+ entry = {'kind': 'creep', 'window': window,
225
+ 'interval_secs': 600, 'step': 0.1, 'max': 3.0,
226
+ 'priority': None, 'start_date': None}
227
+ for tok in first_parts[2:]:
228
+ if '=' not in tok:
229
+ raise ValueError(f"creep param needs key=value: {tok!r}")
230
+ k, v = tok.split('=', 1)
231
+ if k == 'interval':
232
+ entry['interval_secs'] = parse_duration(v)
233
+ elif k == 'step':
234
+ entry['step'] = float(v)
235
+ elif k == 'max':
236
+ entry['max'] = float(v)
237
+ else:
238
+ raise ValueError(f"unknown creep param: {k!r}")
239
+ return entry
240
+
241
+
242
+ def parse_entry(line):
243
+ parts = line.split()
244
+ if not parts:
245
+ raise ValueError("empty entry")
246
+
247
+ priority = None
248
+ start_date = None
249
+
250
+ while parts and (parts[0].startswith('priority=') or parts[0].startswith('start=')):
251
+ tok = parts.pop(0)
252
+ if tok.startswith('priority='):
253
+ priority = int(tok.split('=', 1)[1])
254
+ elif tok.startswith('start='):
255
+ start_date = date.fromisoformat(tok.split('=', 1)[1])
256
+
257
+ rejoined = ' '.join(parts)
258
+ chunk_strs = [c.strip() for c in rejoined.split(',')]
259
+
260
+ first_parts = chunk_strs[0].split()
261
+
262
+ if len(first_parts) >= 2 and first_parts[1] == 'creep':
263
+ if len(chunk_strs) > 1:
264
+ raise ValueError("creep entry takes no continuations")
265
+ return _parse_creep(first_parts)
266
+
267
+ if len(first_parts) != 3:
268
+ raise ValueError(f"first chunk needs 3 fields (time speed incline), got: {first_parts}")
269
+ time_range, speed_s, incline_s = first_parts
270
+
271
+ tr = _parse_time_range(time_range)
272
+ if not tr:
273
+ raise ValueError(f"unrecognized time range: {time_range!r}")
274
+
275
+ speed_base, speed_delta = parse_ramp_number(speed_s)
276
+ incline_base, incline_delta = parse_ramp_number(incline_s)
277
+
278
+ entry = {
279
+ **tr,
280
+ 'priority': priority,
281
+ 'start_date': start_date,
282
+ 'speed_base': speed_base, 'speed_delta': speed_delta,
283
+ 'incline_base': incline_base, 'incline_delta': incline_delta,
284
+ 'continuations': [],
285
+ }
286
+
287
+ for chunk_str in chunk_strs[1:]:
288
+ cparts = chunk_str.split()
289
+ if len(cparts) != 3:
290
+ raise ValueError(f"continuation needs 3 fields: {chunk_str!r}")
291
+ ctr, csp, cinc = cparts
292
+ m = re.fullmatch(r'now-now\+(\d+:\d{2})', ctr)
293
+ if not m:
294
+ raise ValueError(f"continuation time must be now-now+MM:SS, got: {ctr!r}")
295
+ duration = parse_mm_ss(m.group(1))
296
+ csp_b, csp_d = parse_ramp_number(csp)
297
+ cinc_b, cinc_d = parse_ramp_number(cinc)
298
+ entry['continuations'].append({
299
+ 'duration_secs': duration,
300
+ 'speed_base': csp_b, 'speed_delta': csp_d,
301
+ 'incline_base': cinc_b, 'incline_delta': cinc_d,
302
+ })
303
+
304
+ if _entry_has_ramp(entry) and entry['start_date'] is None:
305
+ raise ValueError("entry has ramp but no start=YYYY-MM-DD")
306
+
307
+ return entry
308
+
309
+
310
+ def parse_schedule(path):
311
+ entries = []
312
+ for lineno, line in enumerate(path.read_text().splitlines(), start=1):
313
+ line = line.split('#')[0].strip()
314
+ if not line:
315
+ continue
316
+ try:
317
+ entries.append(parse_entry(line))
318
+ except ValueError as err:
319
+ raise ValueError(f"{path}:{lineno}: {err}") from err
320
+ return entries
321
+
322
+
323
+ # ---- effective values ----
324
+
325
+ def eff_speed(e, day):
326
+ return e['speed_base'] + e['speed_delta'] * day
327
+
328
+
329
+ def eff_incline(e, day):
330
+ return e['incline_base'] + e['incline_delta'] * day
331
+
332
+
333
+ def eff_end_offset(e, day):
334
+ return int(e['end_offset'] + e.get('end_delta_per_day', 0) * day)
335
+
336
+
337
+ def first_chunk_duration(entry, day):
338
+ """Full duration of the first chunk (absent partial-window adjustment)."""
339
+ if entry['kind'] == 'hourly':
340
+ return (entry['end_secs'] - entry['start_secs']) % 3600 or 3600
341
+ if entry['kind'] == 'absolute':
342
+ s = entry['start_h'] * 3600 + entry['start_m'] * 60
343
+ e = entry['end_h'] * 3600 + entry['end_m'] * 60
344
+ return (e - s) % 86400 or 86400
345
+ if entry['kind'] == 'day':
346
+ return eff_end_offset(entry, day) - entry['start_offset']
347
+ return 0
348
+
349
+
350
+ def chunks_for(entry, state, first_duration_override=None):
351
+ """List of {duration_secs, speed, incline} for the entry's full sequence."""
352
+ day = entry_day(entry, state)
353
+ chunks = []
354
+ dur = first_duration_override if first_duration_override is not None else first_chunk_duration(entry, day)
355
+ chunks.append({
356
+ 'duration_secs': dur,
357
+ 'speed': eff_speed(entry, day),
358
+ 'incline': eff_incline(entry, day),
359
+ })
360
+ for c in entry.get('continuations', []):
361
+ chunks.append({
362
+ 'duration_secs': c['duration_secs'],
363
+ 'speed': c['speed_base'] + c['speed_delta'] * day,
364
+ 'incline': c['incline_base'] + c['incline_delta'] * day,
365
+ })
366
+ return chunks
367
+
368
+
369
+ # ---- cumulative belt-time tracking ----
370
+
371
+ def update_cumulative(state, treadmill, increment_secs):
372
+ today = date.today().isoformat()
373
+ if state.get('cum_run_date') != today:
374
+ state['cum_run_date'] = today
375
+ state['cum_run_secs'] = 0
376
+ save_state(state)
377
+ if is_running(treadmill):
378
+ state['cum_run_secs'] = state.get('cum_run_secs', 0) + increment_secs
379
+ save_state(state)
380
+
381
+
382
+ def fired_today(state, e):
383
+ return state.get('last_fired', {}).get(_entry_key(e)) == date.today().isoformat()
384
+
385
+
386
+ def mark_fired(state, e):
387
+ state.setdefault('last_fired', {})[_entry_key(e)] = date.today().isoformat()
388
+ save_state(state)
389
+
390
+
391
+ def _entry_key(e):
392
+ if e['kind'] == 'day':
393
+ return f"day:{e['start_offset']}"
394
+ if e['kind'] == 'hourly':
395
+ return f"hourly:{e['start_secs']}-{e['end_secs']}"
396
+ if e['kind'] == 'absolute':
397
+ return f"abs:{e['start_h']}:{e['start_m']}"
398
+ return repr(e)
399
+
400
+
401
+ # ---- readiness check ----
402
+
403
+ def is_ready_now(entry, now, state):
404
+ """If the first chunk's window is open now, return remaining seconds. Else None."""
405
+ if entry['kind'] == 'hourly':
406
+ cur = now.minute * 60 + now.second
407
+ start, end = entry['start_secs'], entry['end_secs']
408
+ if start <= cur < end:
409
+ return end - cur
410
+ return None
411
+ if entry['kind'] == 'absolute':
412
+ cur = now.hour * 3600 + now.minute * 60 + now.second
413
+ start = entry['start_h'] * 3600 + entry['start_m'] * 60
414
+ end = entry['end_h'] * 3600 + entry['end_m'] * 60
415
+ if start <= cur < end:
416
+ return end - cur
417
+ return None
418
+ if entry['kind'] == 'day':
419
+ if fired_today(state, entry):
420
+ return None
421
+ cum = state.get('cum_run_secs', 0)
422
+ if cum < entry['start_offset']:
423
+ return None
424
+ day = entry_day(entry, state)
425
+ dur = eff_end_offset(entry, day) - entry['start_offset']
426
+ if dur <= 0:
427
+ return None
428
+ return dur
429
+ return None
430
+
431
+
432
+ def priority_lt(a, b):
433
+ """Is priority a strictly lower than priority b? None < any int."""
434
+ if a is None and b is None:
435
+ return False
436
+ if a is None:
437
+ return True
438
+ if b is None:
439
+ return False
440
+ return a < b
441
+
442
+
443
+ def _note(prev, msg):
444
+ """Print `msg` only when it differs from the last note; return it."""
445
+ if msg != prev:
446
+ print(f"treadmill-cron: {msg}")
447
+ return msg
448
+
449
+
450
+ # ---- daemon ----
451
+
452
+ def daemon():
453
+ schedule_path = Path(sys.argv[1]) if len(sys.argv) > 1 else SCHEDULE_FILE
454
+ print(f"treadmill-cron: watching {schedule_path}")
455
+
456
+ cfg = load_config()
457
+ state = load_state()
458
+ if 'start_date' not in state:
459
+ state['start_date'] = date.today().isoformat()
460
+ save_state(state)
461
+
462
+ last_tick = time.monotonic()
463
+
464
+ running_entry = None
465
+ remaining_chunks: list = []
466
+ chunk_end_mono = None
467
+ prev_speed = None
468
+ prev_incline = None
469
+ last_announced_evt = None
470
+ creep_accum = 0.0 # belt-time accrued toward the next creep nudge
471
+ creep_note = None # dedupes the "at max" log line
472
+
473
+ def stop_running(restore: bool):
474
+ nonlocal running_entry, remaining_chunks, chunk_end_mono
475
+ if running_entry is None:
476
+ return
477
+ if restore and prev_speed is not None:
478
+ tm = get_treadmill_state()
479
+ if is_running(tm):
480
+ ctl('speed', str(prev_speed))
481
+ ctl('incline', str(prev_incline))
482
+ running_entry = None
483
+ remaining_chunks = []
484
+ chunk_end_mono = None
485
+
486
+ while True:
487
+ try:
488
+ entries = parse_schedule(schedule_path)
489
+ except FileNotFoundError:
490
+ print(f"treadmill-cron: schedule {schedule_path} not found")
491
+ return
492
+
493
+ treadmill = get_treadmill_state()
494
+ now_mono = time.monotonic()
495
+ dt = now_mono - last_tick
496
+ update_cumulative(state, treadmill, dt)
497
+ last_tick = now_mono
498
+ now = datetime.now()
499
+
500
+ # Treadmill stopped while running an entry -> abort, no restore
501
+ if running_entry and not is_running(treadmill):
502
+ print("treadmill-cron: treadmill stopped, aborting current entry")
503
+ running_entry = None
504
+ remaining_chunks = []
505
+ chunk_end_mono = None
506
+
507
+ # Advance current chunk if its time is up
508
+ if running_entry and chunk_end_mono is not None and now_mono >= chunk_end_mono:
509
+ remaining_chunks.pop(0)
510
+ if remaining_chunks:
511
+ chunk = remaining_chunks[0]
512
+ ctl('speed', str(chunk['speed']))
513
+ ctl('incline', str(chunk['incline']))
514
+ chunk_end_mono = now_mono + chunk['duration_secs']
515
+ print(f"treadmill-cron: chunk -> {chunk['speed']:.1f} kph "
516
+ f"{chunk['incline']:.1f}% for {chunk['duration_secs']}s")
517
+ else:
518
+ if running_entry['kind'] == 'day':
519
+ mark_fired(state, running_entry)
520
+ stop_running(restore=True)
521
+
522
+ # Find best candidate to fire now
523
+ best = None # (priority_sort_key, entry, remaining_secs)
524
+ for e in entries:
525
+ rem = is_ready_now(e, now, state)
526
+ if rem is None:
527
+ continue
528
+ ep = e.get('priority')
529
+ key = ep if ep is not None else float('-inf')
530
+ if best is None or key > best[0]:
531
+ best = (key, e, rem)
532
+
533
+ if best:
534
+ _, candidate, rem = best
535
+ cp = candidate.get('priority')
536
+ should_start = False
537
+ if running_entry is None:
538
+ should_start = True
539
+ elif candidate is not running_entry:
540
+ rp = running_entry.get('priority')
541
+ if priority_lt(rp, cp):
542
+ should_start = True
543
+
544
+ if should_start and is_running(treadmill):
545
+ if running_entry is not None:
546
+ print(f"treadmill-cron: preempting {_entry_key(running_entry)}")
547
+ stop_running(restore=False)
548
+
549
+ chunks = chunks_for(candidate, state, first_duration_override=rem)
550
+ running_entry = candidate
551
+ remaining_chunks = chunks
552
+ prev_speed = treadmill.get('speed_kph', 0)
553
+ prev_incline = treadmill.get('incline_pct', 0)
554
+ chunk = chunks[0]
555
+ ctl('speed', str(chunk['speed']))
556
+ ctl('incline', str(chunk['incline']))
557
+ chunk_end_mono = now_mono + chunk['duration_secs']
558
+ tag = candidate['kind']
559
+ print(f"treadmill-cron: start {tag} (p={cp}) "
560
+ f"{chunk['speed']:.1f} kph {chunk['incline']:.1f}% "
561
+ f"for {chunk['duration_secs']}s "
562
+ f"(was {prev_speed:.1f} kph {prev_incline:.1f}%)")
563
+ if tag in cfg.get('notify_kinds', []):
564
+ notify(cfg, f"treadmill: {tag}",
565
+ f"{chunk['speed']:.1f} kph {chunk['incline']:.1f}% "
566
+ f"for {chunk['duration_secs']}s")
567
+ last_announced_evt = None
568
+
569
+ # Light status when idle
570
+ if not running_entry:
571
+ evt = _next_announce(entries, now, state)
572
+ if evt and evt != last_announced_evt:
573
+ print(f"treadmill-cron: next {evt}")
574
+ last_announced_evt = evt
575
+
576
+ # Creep: gentle upward pressure during free walking. Lowest priority --
577
+ # only acts when no entry is running, while moving, inside a creep
578
+ # window. Accumulator only advances while creeping, so stepping off or
579
+ # leaving a window pauses (doesn't restart) the climb. Climbs from the
580
+ # measured speed, so a manual slow-down lowers where the next nudge starts.
581
+ active_creep = next(
582
+ (c for c in entries
583
+ if c['kind'] == 'creep' and creep_window_open(c['window'], now)),
584
+ None)
585
+ if running_entry is None and is_running(treadmill) and active_creep is not None:
586
+ creep_accum += dt
587
+ if creep_accum >= active_creep['interval_secs']:
588
+ creep_accum = 0.0
589
+ cur = round(treadmill.get('speed_kph', 0), 1)
590
+ ceil = active_creep['max']
591
+ new = round(min(ceil, cur + active_creep['step']), 1)
592
+ if new > cur:
593
+ ctl('speed', str(new))
594
+ print(f"treadmill-cron: creep {cur:.1f} -> {new:.1f} kph")
595
+ creep_note = None
596
+ else:
597
+ creep_note = _note(creep_note, f"creep at max {ceil:.1f} kph")
598
+
599
+ time.sleep(TICK_SECS)
600
+
601
+
602
+ def _next_announce(entries, now, state):
603
+ """Loose preview of the next scheduled event, for logging only."""
604
+ best = None
605
+ for e in entries:
606
+ day = entry_day(e, state)
607
+ if e['kind'] == 'hourly':
608
+ cur = now.minute * 60 + now.second
609
+ wait = (e['start_secs'] - cur) % 3600
610
+ cand = now + timedelta(seconds=wait)
611
+ elif e['kind'] == 'absolute':
612
+ cand = now.replace(hour=e['start_h'], minute=e['start_m'],
613
+ second=0, microsecond=0)
614
+ if cand < now:
615
+ cand += timedelta(days=1)
616
+ elif e['kind'] == 'day':
617
+ if fired_today(state, e):
618
+ continue
619
+ need = e['start_offset'] - state.get('cum_run_secs', 0)
620
+ if need <= 0:
621
+ continue
622
+ cand = None # cumulative-driven; no wall-clock prediction
623
+ else:
624
+ continue
625
+ if cand is None:
626
+ label = f"day(p={e.get('priority')}) need {need:.0f}s more belt-time"
627
+ else:
628
+ label = (f"{e['kind']}(p={e.get('priority')}) at "
629
+ f"{cand.strftime('%H:%M:%S')} "
630
+ f"{eff_speed(e, day):.2f} kph {eff_incline(e, day):.2f}%")
631
+ if best is None or (cand is not None and (best[0] is None or cand < best[0])):
632
+ best = (cand, label)
633
+ return best[1] if best else None
634
+
635
+
636
+ # ---- subcommands ----
637
+
638
+ def fmt_secs(s):
639
+ return f"{s//60:02d}:{s%60:02d}"
640
+
641
+
642
+ def cmd_status():
643
+ s = load_state()
644
+ entries = parse_schedule(SCHEDULE_FILE)
645
+ print(f"daemon start_date: {s.get('start_date', '?')}")
646
+ if s.get('held_count'):
647
+ print(f"held: {s['held_count']} day(s) skipped")
648
+ cum = s.get('cum_run_secs', 0)
649
+ if s.get('cum_run_date') == date.today().isoformat():
650
+ print(f"belt-time today: {cum:.0f}s ({cum/60:.1f} min)")
651
+ if s.get('last_fired'):
652
+ print(f"last_fired: {s['last_fired']}")
653
+ print()
654
+ for e in entries:
655
+ if e['kind'] == 'creep':
656
+ win = e['window']
657
+ if win is None:
658
+ wstr = '*'
659
+ elif win['kind'] == 'hourly':
660
+ wstr = f":{fmt_secs(win['start_secs'])}-:{fmt_secs(win['end_secs'])}"
661
+ else:
662
+ wstr = (f"{win['start_h']:02d}:{win['start_m']:02d}-"
663
+ f"{win['end_h']:02d}:{win['end_m']:02d}")
664
+ print(f" creep {wstr:<13s} +{e['step']} kph / "
665
+ f"{e['interval_secs']}s -> max {e['max']:.2f} kph")
666
+ continue
667
+ day = entry_day(e, s)
668
+ sp, inc = eff_speed(e, day), eff_incline(e, day)
669
+ prio = e.get('priority')
670
+ prio_str = f"p={prio}" if prio is not None else "p=-"
671
+ sd = e.get('start_date')
672
+ sd_str = f" since {sd} (day {day})" if sd else ""
673
+ if e['kind'] == 'hourly':
674
+ base = (f" hourly :{fmt_secs(e['start_secs'])}-:{fmt_secs(e['end_secs'])} "
675
+ f"{sp:.2f} kph {inc:.2f}%")
676
+ elif e['kind'] == 'absolute':
677
+ base = (f" abs {e['start_h']:02d}:{e['start_m']:02d}-"
678
+ f"{e['end_h']:02d}:{e['end_m']:02d} {sp:.2f} kph {inc:.2f}%")
679
+ elif e['kind'] == 'day':
680
+ end_off = eff_end_offset(e, day)
681
+ dur = end_off - e['start_offset']
682
+ fired = ' [fired today]' if fired_today(s, e) else ''
683
+ base = (f" day day+{fmt_secs(e['start_offset'])}-day+{fmt_secs(end_off)} "
684
+ f"({dur}s) {sp:.2f} kph {inc:.2f}%{fired}")
685
+ else:
686
+ continue
687
+ cont_str = ''
688
+ for c in e.get('continuations', []):
689
+ cs = c['speed_base'] + c['speed_delta'] * day
690
+ ci = c['incline_base'] + c['incline_delta'] * day
691
+ cont_str += f", +{c['duration_secs']}s @ {cs:.2f} kph {ci:.2f}%"
692
+ print(f"{base} [{prio_str}{sd_str}]{cont_str}")
693
+
694
+
695
+ def cmd_hold():
696
+ s = load_state()
697
+ s['held_count'] = s.get('held_count', 0) + 1
698
+ save_state(s)
699
+ print(f"treadmill-cron: held. day = {days_elapsed(s)}")
700
+
701
+
702
+ def cmd_reset():
703
+ s = load_state()
704
+ s['start_date'] = date.today().isoformat()
705
+ s['held_count'] = 0
706
+ s.pop('last_fired', None)
707
+ s.pop('cum_run_date', None)
708
+ s.pop('cum_run_secs', None)
709
+ save_state(s)
710
+ print("treadmill-cron: reset. day = 0, cumulative cleared")
711
+
712
+
713
+ def main():
714
+ if len(sys.argv) >= 2 and sys.argv[1] in ('status', 'hold', 'reset'):
715
+ return {'status': cmd_status, 'hold': cmd_hold, 'reset': cmd_reset}[sys.argv[1]]()
716
+ daemon()
717
+
718
+
719
+ if __name__ == '__main__':
720
+ try:
721
+ main()
722
+ except KeyboardInterrupt:
723
+ print("\ntreadmill-cron: stopped")