Release-prep work for a publish-worthy 0.2.0: - LICENSE: MIT, © 2026 Stephen Waits. - README.md: tagline, what-it-does, hardware, build/flash recipes, ASCII statechart overview, "Why four hours…" runtime-tuning rationale, USB identity section. - Cargo.toml: 0.1.0 → 0.2.0; description, license, repository, readme, keywords, categories; publish = false (firmware, not a library); release profile tightened (lto = "fat", opt-level = "z", panic = "abort"). Flashed binary stays 47 KB; the size knobs are explicit rather than relying on defaults. - CHANGELOG.md: collapse Unreleased → [0.2.0] - 2026-05-01. - scripts/tune_runtime.py: 4-D Monte Carlo over (RUN_DURATION, YELLOW_AT, RED_AT, FAST_RED_AT). PEP 723 inline deps so `uv run` just works. Runtime + LED thresholds re-derived from a typical office workday distribution with a per-minute press-on-warning user model. Joint optimum: RUN_DURATION: 4h00m YELLOW_AT / RED_AT / FAST_RED_AT (min remaining): 30 / 25 / 20 USB identity: VID/PID: 046d:c07d (G502) → 1209:b0b0 (pid.codes) manufacturer: "Logitech" → "swaits.com" product: "G502 Mouse" → "jiggly" bcdDevice: default 0x0010 → 0x0200 (matches firmware version) serial: (none) → RP2040 chip ID as 16 hex chars Bug fix in the descriptor change: an interim version spoofed the Logitech Unifying Receiver (046d:c52b). On Linux, `hid-logitech-dj` matches that exact PID and tries to talk Logitech's HID++ protocol to enumerate paired wireless devices. The firmware doesn't speak HID++, so the driver waits through ~10–20 s of control-transfer timeouts on every plug before unbinding and letting `hid-generic` actually start polling. macOS has no such driver and was always fast. Moving to a pid.codes VID routes the device straight to `hid-generic`. Wake key: tapped Left Shift in early versions to wake the host. In practice that turned out to be exactly the nightmare scenario it sounds like — if the deadline preempted the loop between a Shift-down report and its Shift-up, the host would silently capitalise every keystroke from the user's real keyboard until the device was unplugged. Switched to F13: still wakes any modern OS, but no mainstream OS maps F13 by default, so a stuck F13 has zero visible effect. Also added an unconditional all-keys-released cleanup report at the end of the wake action, bounded by KBD_RELEASE_DEADLINE = 100 ms, so even a deadline that fires mid-press can't leave anything held. Wake refactor: WakingWithKeyboard and WakingWithMouse use oneshot `entry:` actions that race their work against an internal deadline via `embassy_futures::select`; the chart timer (`on(after KBD_PHASE_DURATION)` / `on(after MOUSE_PHASE_DURATION)`) advances. Removes a class of "slow USB ⇒ chart stalls" failure modes from the wake path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
272 lines
9.4 KiB
Python
272 lines
9.4 KiB
Python
# /// script
|
||
# requires-python = ">=3.10"
|
||
# dependencies = ["numpy"]
|
||
# ///
|
||
"""
|
||
Monte Carlo tuner for the four lifecycle constants in src/main.rs:
|
||
|
||
RUN_DURATION full cycle length, in minutes
|
||
YELLOW_AT remaining-minute threshold where breathing-yellow begins
|
||
RED_AT remaining-minute threshold where breathing-red begins
|
||
FAST_RED_AT remaining-minute threshold where the fast-pulse blink begins
|
||
|
||
Sweeps a 4-D grid (with the constraint YELLOW_AT > RED_AT > FAST_RED_AT > 0)
|
||
across 50 000 simulated workdays and picks the combination that lands the
|
||
screen-sleep in the lunch hour as often as possible.
|
||
|
||
The model
|
||
|
||
- Day starts at Triangular(8:00, mode 8:30, 9:30) and ends at
|
||
Triangular(16:00, mode 17:30, 19:00). Lunch is 12:00–13:00 (fixed).
|
||
- Free RESET at start (boot) and at 13:00 (re-login after lunch).
|
||
- User-at-desk minute-by-minute, sees the LED, and may tap RESET to
|
||
extend the cycle:
|
||
yellow 1.5 % / min
|
||
red 4.0 % / min
|
||
fast-red 6.0 % / min
|
||
warning10/5 one-shot bumps the minute the spiral animation fires
|
||
- A composite score rewards lunch-hour expiration (especially the
|
||
12:15–12:45 sweet spot) and penalizes daytime failures.
|
||
|
||
Usage
|
||
|
||
uv run scripts/tune_runtime.py # default 50 000 days
|
||
uv run scripts/tune_runtime.py --n 100000 # finer Monte Carlo
|
||
|
||
Adjust the per-phase press probabilities and grid ranges at the top of
|
||
main() to match your own behavior or your own workday distribution.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import itertools
|
||
import time
|
||
|
||
import numpy as np
|
||
|
||
LUNCH_START = 12 * 60
|
||
LUNCH_END = 13 * 60
|
||
|
||
# Per-minute press probabilities per LED phase.
|
||
P_PRESS_YELLOW = 0.015
|
||
P_PRESS_RED = 0.040
|
||
P_PRESS_FAST_RED = 0.060
|
||
# One-shot bumps when the on-screen spiral animations fire (10 / 5 min before
|
||
# death). Independent of LED-phase boundaries — the firmware fires those at
|
||
# fixed offsets from death.
|
||
P_WARN10_BUMP = 0.04
|
||
P_WARN5_BUMP = 0.03
|
||
|
||
|
||
def sample_days(n: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
|
||
s = (rng.triangular(8.0, 8.5, 9.5, n) * 60).astype(np.int32)
|
||
e = (rng.triangular(16.0, 17.5, 19.0, n) * 60).astype(np.int32)
|
||
return s, e
|
||
|
||
|
||
def simulate(
|
||
rt: int,
|
||
yellow_at: int,
|
||
red_at: int,
|
||
fast_red_at: int,
|
||
s: np.ndarray,
|
||
e: np.ndarray,
|
||
rng: np.random.Generator,
|
||
) -> dict:
|
||
n = len(s)
|
||
expire = s + rt
|
||
|
||
presses = np.zeros(n, dtype=np.int32)
|
||
slept_work = np.zeros(n, dtype=np.int32)
|
||
slept_lunch = np.zeros(n, dtype=np.int32)
|
||
after_hours = np.zeros(n, dtype=np.int32)
|
||
|
||
t_min = int(s.min())
|
||
t_max = int(max(e.max(), expire.max())) + 1
|
||
|
||
for t in range(t_min, t_max):
|
||
# Free re-tap when the user re-logs in at 13:00.
|
||
if t == LUNCH_END:
|
||
in_workday = (t >= s) & (t < e)
|
||
expire = np.where(in_workday, t + rt, expire)
|
||
|
||
in_workday = (t >= s) & (t < e)
|
||
at_lunch = LUNCH_START <= t < LUNCH_END
|
||
device_running = t < expire
|
||
device_dead = ~device_running
|
||
|
||
if at_lunch:
|
||
slept_lunch += (in_workday & device_dead).astype(np.int32)
|
||
else:
|
||
slept_work += (in_workday & device_dead).astype(np.int32)
|
||
|
||
past_end = (t >= e) & device_running
|
||
after_hours += past_end.astype(np.int32)
|
||
|
||
if not at_lunch:
|
||
eligible = in_workday & device_running
|
||
if eligible.any():
|
||
remaining = expire - t
|
||
p = np.zeros(n, dtype=np.float32)
|
||
yellow = (remaining > red_at) & (remaining <= yellow_at)
|
||
red = (remaining > fast_red_at) & (remaining <= red_at)
|
||
fast_red = (remaining > 0) & (remaining <= fast_red_at)
|
||
p[yellow] = P_PRESS_YELLOW
|
||
p[red] = P_PRESS_RED
|
||
p[fast_red] = P_PRESS_FAST_RED
|
||
p[remaining == 10] += P_WARN10_BUMP
|
||
p[remaining == 5] += P_WARN5_BUMP
|
||
|
||
roll = rng.random(n).astype(np.float32)
|
||
press = eligible & (roll < p)
|
||
np.putmask(expire, press, t + rt)
|
||
presses += press.astype(np.int32)
|
||
|
||
return {
|
||
"rt": rt,
|
||
"yellow_at": yellow_at,
|
||
"red_at": red_at,
|
||
"fast_red_at": fast_red_at,
|
||
"presses": presses,
|
||
"slept_work": slept_work,
|
||
"slept_lunch": slept_lunch,
|
||
"after_hours": after_hours,
|
||
}
|
||
|
||
|
||
def summarize(r: dict) -> dict:
|
||
sw = r["slept_work"]
|
||
sl = r["slept_lunch"]
|
||
ah = r["after_hours"]
|
||
pr = r["presses"]
|
||
sweet = (sl >= 15) & (sl <= 45)
|
||
return {
|
||
"rt": r["rt"],
|
||
"yellow_at": r["yellow_at"],
|
||
"red_at": r["red_at"],
|
||
"fast_red_at": r["fast_red_at"],
|
||
"p_sweet": sweet.mean(),
|
||
"p_lunch_any": (sl > 0).mean(),
|
||
"p_no_work_sleep": (sw == 0).mean(),
|
||
"mean_lunch": sl.mean(),
|
||
"mean_work_sleep": sw.mean(),
|
||
"mean_presses": pr.mean(),
|
||
"mean_after": ah.mean(),
|
||
}
|
||
|
||
|
||
def score(r: dict) -> float:
|
||
return (
|
||
r["p_sweet"]
|
||
+ 0.5 * r["p_lunch_any"]
|
||
- 1.5 * (1 - r["p_no_work_sleep"])
|
||
- 0.05 * r["mean_after"] / 60
|
||
)
|
||
|
||
|
||
def fmt_h(m: float) -> str:
|
||
m = int(round(m))
|
||
h, mm = divmod(m, 60)
|
||
return f"{h}h{mm:02d}m" if h else f"{mm}m"
|
||
|
||
|
||
def fmt_rt(m: int) -> str:
|
||
h, mm = divmod(int(m), 60)
|
||
return f"{h}h{mm:02d}m"
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--n", type=int, default=50_000, help="days per combo")
|
||
ap.add_argument("--seed", type=int, default=2026)
|
||
args = ap.parse_args()
|
||
|
||
# 4-D search grid. Wider/finer is more honest; narrower is faster.
|
||
rt_range = list(range(230, 251, 5)) # 230..250 step 5 (5)
|
||
yellow_at_range = list(range(20, 71, 5)) # 20..70 step 5 (11)
|
||
red_at_range = list(range(10, 41, 5)) # 10..40 step 5 (7)
|
||
fast_red_at_range = list(range(4, 21, 2)) # 4..20 step 2 (9)
|
||
|
||
print(f"tune_runtime — N={args.n} days/combo")
|
||
print(f" RT {rt_range[0]}..{rt_range[-1]} step 5 ({len(rt_range)})")
|
||
print(f" YELLOW_AT {yellow_at_range[0]}..{yellow_at_range[-1]} step 5 ({len(yellow_at_range)})")
|
||
print(f" RED_AT {red_at_range[0]}..{red_at_range[-1]} step 5 ({len(red_at_range)})")
|
||
print(f" FAST_RED_AT {fast_red_at_range[0]}..{fast_red_at_range[-1]} step 2 ({len(fast_red_at_range)})")
|
||
print(f" press: yellow {P_PRESS_YELLOW}/min, red {P_PRESS_RED}/min, "
|
||
f"fast {P_PRESS_FAST_RED}/min")
|
||
print()
|
||
|
||
rng = np.random.default_rng(args.seed)
|
||
s, e = sample_days(args.n, rng)
|
||
|
||
combos = [
|
||
(rt, ya, ra, fra)
|
||
for rt, ya, ra, fra in itertools.product(
|
||
rt_range, yellow_at_range, red_at_range, fast_red_at_range
|
||
)
|
||
if ya > ra > fra > 0
|
||
]
|
||
print(f" {len(combos)} valid combos to evaluate...")
|
||
t0 = time.time()
|
||
|
||
results = []
|
||
for i, (rt, ya, ra, fra) in enumerate(combos):
|
||
sim_rng = np.random.default_rng(args.seed + 1 + i)
|
||
r = simulate(rt, ya, ra, fra, s, e, sim_rng)
|
||
results.append(summarize(r))
|
||
if (i + 1) % 200 == 0:
|
||
elapsed = time.time() - t0
|
||
rate = (i + 1) / elapsed
|
||
eta = (len(combos) - i - 1) / rate
|
||
print(f" ... {i+1}/{len(combos)} ({rate:.1f}/sec, ETA {eta:.0f}s)")
|
||
|
||
print(f" done in {time.time() - t0:.0f}s")
|
||
print()
|
||
|
||
by_score = sorted(results, key=lambda r: -score(r))[:25]
|
||
print("=== top 25 by composite score ===")
|
||
print(f"{'RT':>6} {'YEL':>4} {'RED':>4} {'FST':>4} | "
|
||
f"{'p_sweet':>7} {'p_any':>6} {'p_no_fail':>9} | "
|
||
f"{'lunch':>5} {'work':>4} {'press':>5} {'score':>6}")
|
||
print("-" * 86)
|
||
for r in by_score:
|
||
print(f"{fmt_rt(r['rt']):>6} {r['yellow_at']:>4} {r['red_at']:>4} {r['fast_red_at']:>4} | "
|
||
f"{r['p_sweet']*100:>6.1f}% {r['p_lunch_any']*100:>5.1f}% "
|
||
f"{r['p_no_work_sleep']*100:>8.1f}% | "
|
||
f"{fmt_h(r['mean_lunch']):>5} {fmt_h(r['mean_work_sleep']):>4} "
|
||
f"{r['mean_presses']:>5.2f} {score(r):>6.3f}")
|
||
|
||
# Where does the firmware's currently-shipping combo land?
|
||
shipping = next(
|
||
(r for r in results
|
||
if r["rt"] == 240 and r["yellow_at"] == 30
|
||
and r["red_at"] == 25 and r["fast_red_at"] == 20),
|
||
None,
|
||
)
|
||
if shipping is not None:
|
||
rank = 1 + sum(1 for r in results if score(r) > score(shipping))
|
||
print()
|
||
print("=== current firmware (RT=4h00 YEL=30 RED=25 FST=20) ===")
|
||
print(f" p_sweet={shipping['p_sweet']*100:.1f}% "
|
||
f"p_any={shipping['p_lunch_any']*100:.1f}% "
|
||
f"p_no_fail={shipping['p_no_work_sleep']*100:.1f}% "
|
||
f"score={score(shipping):.3f}")
|
||
print(f" rank = {rank} / {len(results)}")
|
||
|
||
best = by_score[0]
|
||
print()
|
||
print(f"PICK: RT={fmt_rt(best['rt'])} YELLOW_AT={best['yellow_at']} "
|
||
f"RED_AT={best['red_at']} FAST_RED_AT={best['fast_red_at']}")
|
||
print(f" P(sweet 12:15-12:45) = {best['p_sweet']*100:.1f}%")
|
||
print(f" P(any lunch sleep) = {best['p_lunch_any']*100:.1f}%")
|
||
print(f" P(no work fail) = {best['p_no_work_sleep']*100:.1f}%")
|
||
print(f" mean lunch dead = {fmt_h(best['mean_lunch'])}")
|
||
print(f" mean work sleep = {fmt_h(best['mean_work_sleep'])}")
|
||
print(f" mean presses = {best['mean_presses']:.2f}/day")
|
||
print(f" mean after-hrs = {fmt_h(best['mean_after'])}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|