Created
June 11, 2025 09:26
-
-
Save m-doughty/29615249b42bf7469c340528da831087 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| slow_smoke.py – cross-platform “smoking throttle” timer | |
| • Chimes every N minutes between 08:00 and 01:00 (local time) | |
| • Shows Last smoke / Next smoke / Interval / Time-to-next, with red→green | |
| • Press any key to register a smoke | |
| • Adds +1 min to the interval each new day, persisting state in ~/.slow_smoke_state.json | |
| """ | |
| import datetime as dt | |
| import json, os, sys, time, pathlib, signal, shutil, platform, select | |
| # ---------- user-tweakables ---------- | |
| START_HHMM = (8, 0) # first chime 08:00 | |
| END_HHMM = (1, 0) # last chime 01:00 next calendar day | |
| DEFAULT_INTERVAL = 60 # minutes on first run | |
| STATE_FILE = pathlib.Path.home() / ".slow_smoke_state.json" | |
| # Optional: choose a custom sound file per platform | |
| SOUND_FILE_MAC = "/System/Library/Sounds/Submarine.aiff" | |
| SOUND_FILE_LINUX = "/usr/share/sounds/freedesktop/stereo/complete.oga" # common on many distros | |
| SOUND_FILE_WIN = r"C:\Windows\Media\Windows Notify Calendar.wav" | |
| # ------------------------------------- | |
| # ==== colour helpers ========================================================= | |
| ANSI = {"red": "\033[31m", "green": "\033[32m", "reset": "\033[0m"} | |
| def _colour(text, c): | |
| return f"{ANSI[c]}{text}{ANSI['reset']}" if ANSI["red"] else text | |
| # enable colours on Windows if possible | |
| if os.name == "nt": | |
| try: | |
| import colorama; colorama.init() | |
| except ImportError: | |
| ANSI = {"red": "", "green": "", "reset": ""} | |
| # ==== state handling ========================================================= | |
| def _load_state(): | |
| if STATE_FILE.exists(): | |
| with STATE_FILE.open() as f: state = json.load(f) | |
| else: | |
| state = {"interval": DEFAULT_INTERVAL, | |
| "last_increment": dt.date.today().isoformat()} | |
| return state | |
| def _save_state(state): | |
| with STATE_FILE.open("w") as f: json.dump(state, f) | |
| def _bump_interval_if_new_day(state): | |
| today = dt.date.today() | |
| last = dt.date.fromisoformat(state["last_increment"]) | |
| days_passed = (today - last).days | |
| if days_passed: | |
| state["interval"] += days_passed # +1 min per day | |
| state["last_increment"] = today.isoformat() | |
| _save_state(state) | |
| # ==== cross-platform chime =================================================== | |
| def _chime(): | |
| system = platform.system() | |
| if system == "Darwin": | |
| os.system(f"afplay '{SOUND_FILE_MAC}' >/dev/null 2>&1 &") | |
| elif system == "Windows": | |
| import winsound | |
| wav = SOUND_FILE_WIN if pathlib.Path(SOUND_FILE_WIN).exists() else None | |
| try: | |
| winsound.PlaySound(wav or "SystemExclamation", | |
| winsound.SND_ALIAS if wav is None else winsound.SND_FILENAME | winsound.SND_ASYNC) | |
| except RuntimeError: # fallback | |
| winsound.MessageBeep(-1) | |
| else: # Linux / other Unix | |
| for cmd, file in (("paplay", SOUND_FILE_LINUX), | |
| ("aplay", SOUND_FILE_LINUX), | |
| ("play", SOUND_FILE_LINUX)): | |
| if shutil.which(cmd): | |
| os.system(f"{cmd} '{file}' >/dev/null 2>&1 &") | |
| return | |
| # final fallback – console bell | |
| print('\a', end='', flush=True) | |
| # ==== non-blocking keypress ================================================== | |
| if os.name == "nt": | |
| import msvcrt | |
| def _get_key(timeout): | |
| end = time.time() + timeout | |
| while time.time() < end: | |
| if msvcrt.kbhit(): | |
| return msvcrt.getch().decode(errors="ignore") | |
| time.sleep(0.01) | |
| return None | |
| def _enable_raw(): return None | |
| def _restore_raw(_): pass | |
| else: | |
| import termios, tty | |
| def _enable_raw(): | |
| fd = sys.stdin.fileno() | |
| old = termios.tcgetattr(fd) | |
| tty.setcbreak(fd) | |
| return old | |
| def _restore_raw(old): | |
| termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old) | |
| def _get_key(timeout): | |
| ready, _, _ = select.select([sys.stdin], [], [], timeout) | |
| return sys.stdin.read(1) if ready else None | |
| # ==== time helpers =========================================================== | |
| def _seconds_until(target): | |
| return max(0, (target - dt.datetime.now()).total_seconds()) | |
| def _next_chime_time(interval_min): | |
| now = dt.datetime.now() | |
| day_start = now.replace(hour=START_HHMM[0], minute=START_HHMM[1], | |
| second=0, microsecond=0) | |
| day_end = (now if now.hour >= START_HHMM[0] else now - dt.timedelta(days=1) | |
| ).replace(hour=END_HHMM[0], minute=END_HHMM[1], | |
| second=0, microsecond=0) + dt.timedelta(days=1) | |
| if now < day_start: | |
| return day_start | |
| elapsed = (now - day_start).total_seconds() // 60 | |
| increments = int(elapsed // interval_min) + 1 | |
| candidate = day_start + dt.timedelta(minutes=increments * interval_min) | |
| return candidate if candidate <= day_end else day_start + dt.timedelta(days=1) | |
| def _fmt_td(td): | |
| secs = int(td.total_seconds()) | |
| hh, rem = divmod(secs, 3600) | |
| mm, ss = divmod(rem, 60) | |
| return f"{hh:02d}:{mm:02d}:{ss:02d}" | |
| # ==== graceful exit ========================================================== | |
| def _soft_exit(signum, frame): | |
| _restore_raw(orig_termios) | |
| print("\nBye!") | |
| sys.exit(0) | |
| # ===================== main ================================================== | |
| state = _load_state() | |
| _bump_interval_if_new_day(state) | |
| interval = state["interval"] | |
| orig_termios = _enable_raw() | |
| signal.signal(signal.SIGINT, _soft_exit) # clean ^C exit | |
| last_smoke_time = None | |
| needs_smoke = False | |
| while True: | |
| next_time = _next_chime_time(interval) | |
| needs_smoke = True | |
| _chime() # ding! | |
| while True: | |
| now = dt.datetime.now() | |
| if now >= next_time: break | |
| if _get_key(0.2): # pressed anything | |
| last_smoke_time = now | |
| needs_smoke = False | |
| # ---- status line ---------------------------------------------------- | |
| last_str = (last_smoke_time.strftime("%H:%M:%S") | |
| if last_smoke_time else "--:--:--") | |
| last_col = "red" if needs_smoke else "green" | |
| remain_str = _fmt_td(next_time - now) | |
| sys.stdout.write("\r\033[K") # clear line | |
| sys.stdout.write( | |
| f"Last smoke: {_colour(last_str, last_col)} " | |
| f"Next smoke: {next_time.strftime('%H:%M:%S')} " | |
| f"Interval: {interval} min " | |
| f"Time to next: {remain_str}" | |
| ) | |
| sys.stdout.flush() | |
| # new day? bump interval & persist | |
| _bump_interval_if_new_day(state) | |
| interval = state["interval"] |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment