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.
- treadmill_cron-0.1.0/PKG-INFO +3 -0
- treadmill_cron-0.1.0/README.md +38 -0
- treadmill_cron-0.1.0/pyproject.toml +13 -0
- treadmill_cron-0.1.0/setup.cfg +4 -0
- treadmill_cron-0.1.0/treadmill_cron.egg-info/PKG-INFO +3 -0
- treadmill_cron-0.1.0/treadmill_cron.egg-info/SOURCES.txt +8 -0
- treadmill_cron-0.1.0/treadmill_cron.egg-info/dependency_links.txt +1 -0
- treadmill_cron-0.1.0/treadmill_cron.egg-info/entry_points.txt +2 -0
- treadmill_cron-0.1.0/treadmill_cron.egg-info/top_level.txt +1 -0
- treadmill_cron-0.1.0/treadmill_cron.py +723 -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 @@
|
|
|
1
|
+
|
|
@@ -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")
|