Skip to content

Instantly share code, notes, and snippets.

@m-doughty
Created June 11, 2025 09:26
Show Gist options
  • Select an option

  • Save m-doughty/29615249b42bf7469c340528da831087 to your computer and use it in GitHub Desktop.

Select an option

Save m-doughty/29615249b42bf7469c340528da831087 to your computer and use it in GitHub Desktop.
#!/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