15m notification util

This commit is contained in:
Dobromir Popov
2026-02-17 10:30:18 +02:00
parent 22b302d98d
commit eb0288313b
3 changed files with 202 additions and 0 deletions

1
python/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Utils package

View File

@@ -0,0 +1,77 @@
<#
.SYNOPSIS
Plays a ding/timer sound at regular intervals aligned to round period marks (e.g. every 15 min),
with an optional offset so the sound plays a few seconds before each mark.
.DESCRIPTION
Default: period 15 minutes, offset 15 seconds. Notification plays at :14:45, :29:45, :44:45, :59:45.
.PARAMETER PeriodMinutes
Interval in minutes between notifications (default 15). Defines the "round" marks (0, 15, 30, 45 past the hour).
.PARAMETER OffsetSeconds
Seconds before each round mark to play the sound (default 15). E.g. 15 means play at XX:14:45, XX:29:45, etc.
.EXAMPLE
.\interval-timer-ding.ps1
.\interval-timer-ding.ps1 -PeriodMinutes 30 -OffsetSeconds 10
#>
param(
[int] $PeriodMinutes = 15,
[int] $OffsetSeconds = 15
)
$ErrorActionPreference = 'Stop'
if ($PeriodMinutes -lt 1) { throw "PeriodMinutes must be >= 1" }
if ($OffsetSeconds -lt 0) { throw "OffsetSeconds must be >= 0" }
$periodSec = $PeriodMinutes * 60
function Get-SecondsSinceMidnight {
$now = Get-Date
return $now.Hour * 3600 + $now.Minute * 60 + $now.Second
}
function Get-NextRingSeconds {
$nowSec = Get-SecondsSinceMidnight
$nextBoundarySec = [Math]::Ceiling($nowSec / $periodSec) * $periodSec
$ringSec = $nextBoundarySec - $OffsetSeconds
if ($ringSec -le $nowSec) {
$ringSec += $periodSec
}
return $ringSec
}
function Get-WaitSeconds {
$nowSec = Get-SecondsSinceMidnight
$ringSec = Get-NextRingSeconds
$wait = $ringSec - $nowSec
if ($wait -le 0) { $wait = 1 }
return [int]$wait
}
function Play-Ding {
try {
[System.Media.SystemSounds]::Asterisk.Play()
} catch {
[Console]::Beep(800, 200)
}
}
$nextRing = Get-NextRingSeconds
$nextH = [Math]::Floor($nextRing / 3600)
$nextM = [Math]::Floor(($nextRing % 3600) / 60)
$nextS = $nextRing % 60
Write-Host "Period: $PeriodMinutes min, offset: $OffsetSeconds s. Next ding at $($nextH.ToString('00')):$($nextM.ToString('00')):$($nextS.ToString('00')) (then every $PeriodMinutes min). Press Ctrl+C to stop."
while ($true) {
$waitSec = Get-WaitSeconds
if ($waitSec -gt 0) {
Start-Sleep -Seconds $waitSec
}
Play-Ding
$ts = Get-Date -Format "HH:mm:ss"
Write-Host "[$ts] ding"
}

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Plays a ding/timer sound at regular intervals aligned to round period marks
(e.g. every 15 min), with an optional offset so the sound plays a few seconds
before each mark.
Default: period 15 minutes, offset 15 seconds.
Notification plays at :14:45, :29:45, :44:45, :59:45.
Works on Windows and Linux (stdlib + optional system players on Linux).
"""
import argparse
import math
import subprocess
import sys
import time
from datetime import datetime
def _seconds_since_midnight(dt: datetime) -> int:
return dt.hour * 3600 + dt.minute * 60 + dt.second
def _next_ring_seconds(period_sec: int, offset_sec: int, now_sec: int) -> int:
next_boundary = math.ceil(now_sec / period_sec) * period_sec
ring_sec = next_boundary - offset_sec
if ring_sec <= now_sec:
ring_sec += period_sec
return ring_sec
def _wait_seconds(period_sec: int, offset_sec: int) -> int:
now = datetime.now()
now_sec = _seconds_since_midnight(now)
ring_sec = _next_ring_seconds(period_sec, offset_sec, now_sec)
wait = ring_sec - now_sec
return max(1, wait) if wait > 0 else 1
def _play_ding_windows() -> None:
try:
import winsound
winsound.MessageBeep(winsound.MB_ICONASTERISK)
except Exception:
try:
import winsound
winsound.Beep(800, 200)
except Exception:
sys.stdout.write("\a")
sys.stdout.flush()
def _play_ding_linux() -> None:
candidates = [
["paplay", "/usr/share/sounds/freedesktop/stereo/bell.oga"],
["paplay", "/usr/share/sounds/freedesktop/stereo/message.oga"],
["aplay", "/usr/share/sounds/alsa/Front_Center.wav"],
["aplay", "/usr/share/sounds/speech-dispatcher/test.wav"],
]
for cmd in candidates:
try:
subprocess.run(cmd, capture_output=True, timeout=2)
return
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
sys.stdout.write("\a")
sys.stdout.flush()
def play_ding() -> None:
if sys.platform == "win32":
_play_ding_windows()
else:
_play_ding_linux()
def main() -> None:
parser = argparse.ArgumentParser(
description="Play a ding every N minutes, offset seconds before each round mark."
)
parser.add_argument(
"-p", "--period",
type=int,
default=15,
metavar="MIN",
help="Interval in minutes between notifications (default: 15)",
)
parser.add_argument(
"-o", "--offset",
type=int,
default=15,
metavar="SEC",
help="Seconds before each round mark to play (default: 15)",
)
args = parser.parse_args()
if args.period < 1:
parser.error("period must be >= 1")
if args.offset < 0:
parser.error("offset must be >= 0")
period_sec = args.period * 60
now = datetime.now()
now_sec = _seconds_since_midnight(now)
next_ring = _next_ring_seconds(period_sec, args.offset, now_sec)
h = next_ring // 3600
m = (next_ring % 3600) // 60
s = next_ring % 60
print(
f"Period: {args.period} min, offset: {args.offset} s. "
f"Next ding at {h:02d}:{m:02d}:{s:02d} (then every {args.period} min). "
"Press Ctrl+C to stop."
)
while True:
wait_sec = _wait_seconds(period_sec, args.offset)
time.sleep(wait_sec)
play_ding()
print(f"[{datetime.now().strftime('%H:%M:%S')}] ding")
if __name__ == "__main__":
main()