alarmy-cli 1.0.1__py3-none-any.whl
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.
- alarm_clock/__init__.py +4 -0
- alarm_clock/audio.py +66 -0
- alarm_clock/cli.py +489 -0
- alarm_clock/logger.py +32 -0
- alarm_clock/models.py +164 -0
- alarm_clock/os_scheduler.py +186 -0
- alarm_clock/scheduler.py +305 -0
- alarm_clock/ui.py +161 -0
- alarmy_cli-1.0.1.dist-info/METADATA +151 -0
- alarmy_cli-1.0.1.dist-info/RECORD +15 -0
- alarmy_cli-1.0.1.dist-info/WHEEL +5 -0
- alarmy_cli-1.0.1.dist-info/entry_points.txt +2 -0
- alarmy_cli-1.0.1.dist-info/top_level.txt +2 -0
- tests/__init__.py +3 -0
- tests/test_scheduler.py +288 -0
alarm_clock/__init__.py
ADDED
alarm_clock/audio.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import threading
|
|
3
|
+
import platform
|
|
4
|
+
import sys
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class AlarmSoundController:
|
|
10
|
+
"""
|
|
11
|
+
Manages non-blocking audio alerts across platforms.
|
|
12
|
+
On Windows, it uses the built-in winsound.Beep API.
|
|
13
|
+
On Linux, it uses the terminal bell character (\a) with a quiet sleep.
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._stop_event = threading.Event()
|
|
17
|
+
self._thread: Optional[threading.Thread] = None
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
|
|
20
|
+
def _beep_loop(self) -> None:
|
|
21
|
+
system = platform.system()
|
|
22
|
+
while not self._stop_event.is_set():
|
|
23
|
+
if system == "Windows":
|
|
24
|
+
try:
|
|
25
|
+
import winsound
|
|
26
|
+
# 1000 Hz frequency, 600 ms duration
|
|
27
|
+
winsound.Beep(1000, 600)
|
|
28
|
+
except Exception as e:
|
|
29
|
+
# Fallback to ASCII bell if winsound fails
|
|
30
|
+
sys.stdout.write('\a')
|
|
31
|
+
sys.stdout.flush()
|
|
32
|
+
else:
|
|
33
|
+
# Linux/macOS fallback using ASCII bell character
|
|
34
|
+
sys.stdout.write('\a')
|
|
35
|
+
sys.stdout.flush()
|
|
36
|
+
|
|
37
|
+
# Sleep in small increments to respond quickly to stop events
|
|
38
|
+
for _ in range(10):
|
|
39
|
+
if self._stop_event.is_set():
|
|
40
|
+
break
|
|
41
|
+
time.sleep(0.1)
|
|
42
|
+
|
|
43
|
+
def start(self) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Starts the alarm beep loop in a background thread if not already running.
|
|
46
|
+
Returns True if a new thread was started, False otherwise.
|
|
47
|
+
"""
|
|
48
|
+
with self._lock:
|
|
49
|
+
if self._thread and self._thread.is_alive():
|
|
50
|
+
return False # Already running
|
|
51
|
+
|
|
52
|
+
self._stop_event.clear()
|
|
53
|
+
self._thread = threading.Thread(target=self._beep_loop, name="AlarmSoundThread", daemon=True)
|
|
54
|
+
self._thread.start()
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def stop(self) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Stops the alarm beep loop.
|
|
60
|
+
"""
|
|
61
|
+
with self._lock:
|
|
62
|
+
if self._thread and self._thread.is_alive():
|
|
63
|
+
self._stop_event.set()
|
|
64
|
+
# Wait briefly for the thread to stop, but don't block indefinitely
|
|
65
|
+
self._thread.join(timeout=1.5)
|
|
66
|
+
self._thread = None
|
alarm_clock/cli.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import datetime
|
|
3
|
+
import time
|
|
4
|
+
import threading
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from alarm_clock.models import Alarm, AlarmState, parse_days
|
|
8
|
+
from alarm_clock.scheduler import AlarmScheduler, parse_time
|
|
9
|
+
from alarm_clock.ui import TerminalUI, Colors, enable_ansi_support, safe_print
|
|
10
|
+
|
|
11
|
+
def on_alarm_trigger(alarm: Alarm) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Callback executed when an alarm fires during interactive mode.
|
|
14
|
+
"""
|
|
15
|
+
TerminalUI.print_alarm_trigger(alarm)
|
|
16
|
+
sys.stdout.write(f"\n({datetime.datetime.now().strftime('%H:%M:%S')}) alarm-clock > ")
|
|
17
|
+
sys.stdout.flush()
|
|
18
|
+
|
|
19
|
+
def run_add_wizard(scheduler: AlarmScheduler) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Guides the user step-by-step to create an alarm with input validation.
|
|
22
|
+
"""
|
|
23
|
+
safe_print(f"\n{Colors.CYAN}{Colors.BOLD}--- Interactive Alarm Setup Wizard ---{Colors.RESET}")
|
|
24
|
+
|
|
25
|
+
# 1. Prompt Time
|
|
26
|
+
while True:
|
|
27
|
+
try:
|
|
28
|
+
time_input = input("Enter alarm time (HH:MM or HH:MM PM) [e.g., 08:30]: ").strip()
|
|
29
|
+
if not time_input:
|
|
30
|
+
safe_print(f"{Colors.RED}Time cannot be empty.{Colors.RESET}")
|
|
31
|
+
continue
|
|
32
|
+
parse_time(time_input)
|
|
33
|
+
break
|
|
34
|
+
except ValueError as e:
|
|
35
|
+
safe_print(f"{Colors.RED}Error: {e}{Colors.RESET}")
|
|
36
|
+
except KeyboardInterrupt:
|
|
37
|
+
safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# 2. Prompt Label
|
|
41
|
+
try:
|
|
42
|
+
label_input = input("Enter alarm label [default: 'Alarm']: ").strip()
|
|
43
|
+
label = label_input if label_input else "Alarm"
|
|
44
|
+
except KeyboardInterrupt:
|
|
45
|
+
safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# 3. Prompt Days (Recurrence)
|
|
49
|
+
while True:
|
|
50
|
+
try:
|
|
51
|
+
days_input = input("Repeat on days (e.g. Mon,Wed,Fri, or 'daily', or press Enter for Once): ").strip()
|
|
52
|
+
days = parse_days(days_input)
|
|
53
|
+
break
|
|
54
|
+
except ValueError as e:
|
|
55
|
+
safe_print(f"{Colors.RED}Error: {e}{Colors.RESET}")
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# 4. Prompt Auto-dismiss Seconds
|
|
61
|
+
while True:
|
|
62
|
+
try:
|
|
63
|
+
dismiss_input = input("Auto-dismiss duration in seconds [default: 60]: ").strip()
|
|
64
|
+
if not dismiss_input:
|
|
65
|
+
auto_dismiss = 60
|
|
66
|
+
break
|
|
67
|
+
auto_dismiss = int(dismiss_input)
|
|
68
|
+
if auto_dismiss <= 0:
|
|
69
|
+
safe_print(f"{Colors.RED}Duration must be a positive integer.{Colors.RESET}")
|
|
70
|
+
continue
|
|
71
|
+
break
|
|
72
|
+
except ValueError:
|
|
73
|
+
safe_print(f"{Colors.RED}Please enter a valid integer.{Colors.RESET}")
|
|
74
|
+
except KeyboardInterrupt:
|
|
75
|
+
safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# 5. Prompt Snooze Minutes
|
|
79
|
+
while True:
|
|
80
|
+
try:
|
|
81
|
+
snooze_input = input("Default snooze duration in minutes [default: 5]: ").strip()
|
|
82
|
+
if not snooze_input:
|
|
83
|
+
snooze_minutes = 5
|
|
84
|
+
break
|
|
85
|
+
snooze_minutes = int(snooze_input)
|
|
86
|
+
if snooze_minutes <= 0:
|
|
87
|
+
safe_print(f"{Colors.RED}Snooze duration must be a positive integer.{Colors.RESET}")
|
|
88
|
+
continue
|
|
89
|
+
break
|
|
90
|
+
except ValueError:
|
|
91
|
+
safe_print(f"{Colors.RED}Please enter a valid integer.{Colors.RESET}")
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
safe_print(f"\n{Colors.YELLOW}Setup cancelled.{Colors.RESET}")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# Create the alarm
|
|
97
|
+
try:
|
|
98
|
+
alarm = scheduler.add_alarm(time_input, label, days, auto_dismiss, snooze_minutes)
|
|
99
|
+
recurrence_text = f"repeating on {','.join(alarm.days)}" if alarm.days else "one-time"
|
|
100
|
+
safe_print(f"\n{Colors.GREEN}Success: Created Alarm {alarm.id} for {alarm.time.strftime('%H:%M')} ('{alarm.label}') - {recurrence_text}, auto-dismiss: {alarm.auto_dismiss_sec}s, snooze: {alarm.snooze_duration_min}m.{Colors.RESET}\n")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
safe_print(f"{Colors.RED}Error creating alarm: {e}{Colors.RESET}")
|
|
103
|
+
|
|
104
|
+
def handle_add(scheduler: AlarmScheduler, args: List[str]) -> None:
|
|
105
|
+
# If no arguments, fallback to interactive setup wizard
|
|
106
|
+
if not args:
|
|
107
|
+
run_add_wizard(scheduler)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
days = []
|
|
111
|
+
auto_dismiss = 60
|
|
112
|
+
snooze_minutes = 5
|
|
113
|
+
|
|
114
|
+
# Parse optional --days flag
|
|
115
|
+
if "--days" in args:
|
|
116
|
+
try:
|
|
117
|
+
idx = args.index("--days")
|
|
118
|
+
if idx + 1 < len(args):
|
|
119
|
+
days_str = args[idx + 1]
|
|
120
|
+
days = parse_days(days_str)
|
|
121
|
+
args.pop(idx + 1)
|
|
122
|
+
args.pop(idx)
|
|
123
|
+
else:
|
|
124
|
+
safe_print(f"{Colors.RED}Error: --days flag requires a value (e.g. Mon,Wed).{Colors.RESET}")
|
|
125
|
+
return
|
|
126
|
+
except ValueError as e:
|
|
127
|
+
safe_print(f"{Colors.RED}Error parsing --days: {e}{Colors.RESET}")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Parse optional --auto-dismiss flag
|
|
131
|
+
if "--auto-dismiss" in args:
|
|
132
|
+
try:
|
|
133
|
+
idx = args.index("--auto-dismiss")
|
|
134
|
+
if idx + 1 < len(args):
|
|
135
|
+
dismiss_str = args[idx + 1]
|
|
136
|
+
auto_dismiss = int(dismiss_str)
|
|
137
|
+
if auto_dismiss <= 0:
|
|
138
|
+
raise ValueError("Duration must be a positive integer.")
|
|
139
|
+
args.pop(idx + 1)
|
|
140
|
+
args.pop(idx)
|
|
141
|
+
else:
|
|
142
|
+
safe_print(f"{Colors.RED}Error: --auto-dismiss flag requires an integer value.{Colors.RESET}")
|
|
143
|
+
return
|
|
144
|
+
except ValueError as e:
|
|
145
|
+
safe_print(f"{Colors.RED}Error parsing --auto-dismiss: {e}{Colors.RESET}")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# Parse optional --snooze-minutes flag
|
|
149
|
+
if "--snooze-minutes" in args:
|
|
150
|
+
try:
|
|
151
|
+
idx = args.index("--snooze-minutes")
|
|
152
|
+
if idx + 1 < len(args):
|
|
153
|
+
snooze_str = args[idx + 1]
|
|
154
|
+
snooze_minutes = int(snooze_str)
|
|
155
|
+
if snooze_minutes <= 0:
|
|
156
|
+
raise ValueError("Snooze duration must be a positive integer.")
|
|
157
|
+
args.pop(idx + 1)
|
|
158
|
+
args.pop(idx)
|
|
159
|
+
else:
|
|
160
|
+
safe_print(f"{Colors.RED}Error: --snooze-minutes flag requires an integer value.{Colors.RESET}")
|
|
161
|
+
return
|
|
162
|
+
except ValueError as e:
|
|
163
|
+
safe_print(f"{Colors.RED}Error parsing --snooze-minutes: {e}{Colors.RESET}")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
if not args:
|
|
167
|
+
safe_print(f"{Colors.RED}Error: 'add' command requires a time (HH:MM).{Colors.RESET}")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
time_str = args[0]
|
|
171
|
+
label = " ".join(args[1:]) if len(args) > 1 else "Alarm"
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
alarm = scheduler.add_alarm(time_str, label, days, auto_dismiss, snooze_minutes)
|
|
175
|
+
recurrence_text = f"repeating on {','.join(alarm.days)}" if alarm.days else "one-time"
|
|
176
|
+
safe_print(f"{Colors.GREEN}Success: Created Alarm {alarm.id} for {alarm.time.strftime('%H:%M')} ('{alarm.label}') - {recurrence_text}, auto-dismiss: {alarm.auto_dismiss_sec}s, snooze: {alarm.snooze_duration_min}m.{Colors.RESET}")
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
safe_print(f"{Colors.RED}Error: {e}{Colors.RESET}")
|
|
179
|
+
|
|
180
|
+
def handle_list(scheduler: AlarmScheduler) -> None:
|
|
181
|
+
alarms = scheduler.get_all_alarms()
|
|
182
|
+
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
183
|
+
TerminalUI.print_status_bar(now_str)
|
|
184
|
+
TerminalUI.print_alarms_table(alarms)
|
|
185
|
+
|
|
186
|
+
def handle_remove(scheduler: AlarmScheduler, args: List[str]) -> None:
|
|
187
|
+
if not args:
|
|
188
|
+
safe_print(f"{Colors.RED}Error: 'remove' command requires an alarm ID.{Colors.RESET}")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
alarm_id = int(args[0])
|
|
193
|
+
success = scheduler.remove_alarm(alarm_id)
|
|
194
|
+
if success:
|
|
195
|
+
safe_print(f"{Colors.GREEN}Success: Removed alarm {alarm_id}.{Colors.RESET}")
|
|
196
|
+
else:
|
|
197
|
+
safe_print(f"{Colors.RED}Error: Alarm ID {alarm_id} not found.{Colors.RESET}")
|
|
198
|
+
except ValueError:
|
|
199
|
+
safe_print(f"{Colors.RED}Error: Alarm ID must be an integer.{Colors.RESET}")
|
|
200
|
+
|
|
201
|
+
def handle_snooze(scheduler: AlarmScheduler, args: List[str]) -> None:
|
|
202
|
+
if not args:
|
|
203
|
+
safe_print(f"{Colors.RED}Error: 'snooze' command requires an alarm ID.{Colors.RESET}")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
alarm_id = int(args[0])
|
|
208
|
+
minutes = int(args[1]) if len(args) > 1 else None
|
|
209
|
+
|
|
210
|
+
alarm = scheduler.snooze_alarm(alarm_id, minutes)
|
|
211
|
+
if alarm:
|
|
212
|
+
resume_time = alarm.snooze_until.strftime('%H:%M:%S')
|
|
213
|
+
snooze_len = minutes if minutes is not None else alarm.snooze_duration_min
|
|
214
|
+
safe_print(f"{Colors.GREEN}Success: Alarm {alarm_id} snoozed for {snooze_len} minutes (until {resume_time}).{Colors.RESET}")
|
|
215
|
+
else:
|
|
216
|
+
safe_print(f"{Colors.RED}Error: Alarm ID {alarm_id} not found.{Colors.RESET}")
|
|
217
|
+
except ValueError:
|
|
218
|
+
safe_print(f"{Colors.RED}Error: Invalid arguments. Usage: snooze <ID> [minutes]{Colors.RESET}")
|
|
219
|
+
|
|
220
|
+
def handle_dismiss(scheduler: AlarmScheduler, args: List[str]) -> None:
|
|
221
|
+
if not args:
|
|
222
|
+
safe_print(f"{Colors.RED}Error: 'dismiss' command requires an alarm ID.{Colors.RESET}")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
alarm_id = int(args[0])
|
|
227
|
+
alarm = scheduler.dismiss_alarm(alarm_id)
|
|
228
|
+
if alarm:
|
|
229
|
+
safe_print(f"{Colors.GREEN}Success: Alarm {alarm_id} dismissed.{Colors.RESET}")
|
|
230
|
+
else:
|
|
231
|
+
safe_print(f"{Colors.RED}Error: Alarm ID {alarm_id} not found.{Colors.RESET}")
|
|
232
|
+
except ValueError:
|
|
233
|
+
safe_print(f"{Colors.RED}Error: Alarm ID must be an integer.{Colors.RESET}")
|
|
234
|
+
|
|
235
|
+
def run_daemon() -> None:
|
|
236
|
+
"""
|
|
237
|
+
Runs the Alarm Scheduler process indefinitely in the foreground.
|
|
238
|
+
Monitors database JSON, updates states, and plays audio when alarms trigger.
|
|
239
|
+
"""
|
|
240
|
+
enable_ansi_support()
|
|
241
|
+
safe_print(f"{Colors.CYAN}Starting Alarm Clock Daemon...{Colors.RESET}")
|
|
242
|
+
safe_print(f"{Colors.DIM}Monitoring alarms. Press Ctrl+C to terminate.{Colors.RESET}\n")
|
|
243
|
+
|
|
244
|
+
def daemon_trigger_callback(alarm: Alarm) -> None:
|
|
245
|
+
TerminalUI.print_alarm_trigger(alarm)
|
|
246
|
+
|
|
247
|
+
scheduler = AlarmScheduler(on_trigger_callback=daemon_trigger_callback)
|
|
248
|
+
scheduler.start()
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
while True:
|
|
252
|
+
time.sleep(1)
|
|
253
|
+
except KeyboardInterrupt:
|
|
254
|
+
safe_print(f"\n{Colors.CYAN}Stopping Daemon. Goodbye!{Colors.RESET}")
|
|
255
|
+
finally:
|
|
256
|
+
scheduler.stop()
|
|
257
|
+
|
|
258
|
+
def get_non_blocking_input(stop_event: threading.Event) -> Optional[str]:
|
|
259
|
+
"""
|
|
260
|
+
Reads user input from terminal in a non-blocking manner to watch the stop_event.
|
|
261
|
+
Uses msvcrt on Windows and select on Linux.
|
|
262
|
+
"""
|
|
263
|
+
import platform
|
|
264
|
+
if platform.system() == "Windows":
|
|
265
|
+
import msvcrt
|
|
266
|
+
input_str = ""
|
|
267
|
+
while not stop_event.is_set():
|
|
268
|
+
if msvcrt.kbhit():
|
|
269
|
+
char = msvcrt.getwch()
|
|
270
|
+
if char in ('\r', '\n'):
|
|
271
|
+
sys.stdout.write('\n')
|
|
272
|
+
sys.stdout.flush()
|
|
273
|
+
return input_str
|
|
274
|
+
elif char == '\b': # Backspace
|
|
275
|
+
if len(input_str) > 0:
|
|
276
|
+
input_str = input_str[:-1]
|
|
277
|
+
sys.stdout.write('\b \b')
|
|
278
|
+
sys.stdout.flush()
|
|
279
|
+
elif ord(char) >= 32: # Printable
|
|
280
|
+
input_str += char
|
|
281
|
+
sys.stdout.write(char)
|
|
282
|
+
sys.stdout.flush()
|
|
283
|
+
time.sleep(0.05)
|
|
284
|
+
return None
|
|
285
|
+
else:
|
|
286
|
+
import select
|
|
287
|
+
while not stop_event.is_set():
|
|
288
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.1)
|
|
289
|
+
if ready:
|
|
290
|
+
return sys.stdin.readline().strip()
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
def run_ring(alarm_id: int) -> None:
|
|
294
|
+
"""
|
|
295
|
+
Plays audio buzzer and displays prompt when scheduled task triggers.
|
|
296
|
+
Executed in a short-lived subprocess task window.
|
|
297
|
+
"""
|
|
298
|
+
enable_ansi_support()
|
|
299
|
+
scheduler = AlarmScheduler()
|
|
300
|
+
alarm = scheduler.ring_alarm(alarm_id)
|
|
301
|
+
if not alarm:
|
|
302
|
+
# Alarm deleted, or already handled in another process
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
# Start audio buzz
|
|
306
|
+
scheduler._sound_controller.start()
|
|
307
|
+
|
|
308
|
+
TerminalUI.clear_screen()
|
|
309
|
+
TerminalUI.print_alarm_trigger(alarm)
|
|
310
|
+
|
|
311
|
+
stop_event = threading.Event()
|
|
312
|
+
|
|
313
|
+
def monitor_disk_state() -> None:
|
|
314
|
+
"""
|
|
315
|
+
Monitors database file changes externally or handles sound expiry.
|
|
316
|
+
"""
|
|
317
|
+
while not stop_event.is_set():
|
|
318
|
+
time.sleep(0.5)
|
|
319
|
+
alarms = {a.id: a for a in scheduler.get_all_alarms()}
|
|
320
|
+
if alarm_id not in alarms:
|
|
321
|
+
stop_event.set()
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
current_alarm = alarms[alarm_id]
|
|
325
|
+
if current_alarm.state != AlarmState.RINGING:
|
|
326
|
+
stop_event.set()
|
|
327
|
+
break
|
|
328
|
+
|
|
329
|
+
# Handle sound expiry (auto-dismiss)
|
|
330
|
+
if current_alarm.ring_start_time:
|
|
331
|
+
elapsed = (datetime.datetime.now() - current_alarm.ring_start_time).total_seconds()
|
|
332
|
+
if elapsed >= current_alarm.auto_dismiss_sec:
|
|
333
|
+
scheduler.dismiss_alarm(alarm_id)
|
|
334
|
+
stop_event.set()
|
|
335
|
+
safe_print(f"\n{Colors.YELLOW}Alarm auto-dismissed after {current_alarm.auto_dismiss_sec} seconds.{Colors.RESET}")
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
monitor_thread = threading.Thread(target=monitor_disk_state, name="RingMonitorThread", daemon=True)
|
|
339
|
+
monitor_thread.start()
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
while not stop_event.is_set():
|
|
343
|
+
sys.stdout.write("Press Enter to dismiss, or type 'snooze' to snooze: ")
|
|
344
|
+
sys.stdout.flush()
|
|
345
|
+
|
|
346
|
+
user_input = get_non_blocking_input(stop_event)
|
|
347
|
+
if stop_event.is_set() or user_input is None:
|
|
348
|
+
break
|
|
349
|
+
|
|
350
|
+
cmd = user_input.strip().lower()
|
|
351
|
+
if cmd == "snooze":
|
|
352
|
+
scheduler.snooze_alarm(alarm_id, None) # Pass None to trigger alarm-specific snooze duration
|
|
353
|
+
# Calculate correct snooze length for stdout log print
|
|
354
|
+
snooze_len = alarm.snooze_duration_min
|
|
355
|
+
safe_print(f"{Colors.GREEN}Alarm snoozed for {snooze_len} minutes.{Colors.RESET}")
|
|
356
|
+
break
|
|
357
|
+
else:
|
|
358
|
+
scheduler.dismiss_alarm(alarm_id)
|
|
359
|
+
safe_print(f"{Colors.GREEN}Alarm dismissed.{Colors.RESET}")
|
|
360
|
+
break
|
|
361
|
+
except KeyboardInterrupt:
|
|
362
|
+
scheduler.dismiss_alarm(alarm_id)
|
|
363
|
+
finally:
|
|
364
|
+
stop_event.set()
|
|
365
|
+
scheduler.stop()
|
|
366
|
+
|
|
367
|
+
def print_cli_help() -> None:
|
|
368
|
+
enable_ansi_support()
|
|
369
|
+
TerminalUI.print_banner()
|
|
370
|
+
help_text = f"""
|
|
371
|
+
{Colors.BOLD}CLI Alarm Clock Usage:{Colors.RESET}
|
|
372
|
+
{Colors.GREEN}alarm-clock{Colors.RESET} - Launches the interactive console
|
|
373
|
+
{Colors.GREEN}alarm-clock add{Colors.RESET} - Start the interactive Setup Wizard
|
|
374
|
+
{Colors.GREEN}alarm-clock add <HH:MM> [label] [--days d] [--auto-dismiss s] [--snooze-minutes m]{Colors.RESET}
|
|
375
|
+
- Create a new alarm directly
|
|
376
|
+
Flags:
|
|
377
|
+
--days: repeat on days (e.g. Mon,Wed or daily)
|
|
378
|
+
--auto-dismiss: seconds limit (e.g. 30)
|
|
379
|
+
--snooze-minutes: custom snooze limit (e.g. 10)
|
|
380
|
+
{Colors.GREEN}alarm-clock list{Colors.RESET} - List all alarms and exit
|
|
381
|
+
{Colors.GREEN}alarm-clock remove <ID>{Colors.RESET} - Remove an alarm and exit
|
|
382
|
+
{Colors.GREEN}alarm-clock snooze <ID> [minutes]{Colors.RESET} - Snooze a ringing alarm and exit
|
|
383
|
+
{Colors.GREEN}alarm-clock dismiss <ID>{Colors.RESET} - Dismiss a ringing alarm and exit
|
|
384
|
+
{Colors.GREEN}alarm-clock clear{Colors.RESET} - Wipes the database and cancels all OS tasks
|
|
385
|
+
{Colors.GREEN}alarm-clock daemon{Colors.RESET} - Run the background sound and time monitor
|
|
386
|
+
{Colors.GREEN}alarm-clock help{Colors.RESET} - Show this CLI command usage
|
|
387
|
+
"""
|
|
388
|
+
print(help_text)
|
|
389
|
+
|
|
390
|
+
def run_interactive() -> None:
|
|
391
|
+
"""
|
|
392
|
+
Runs the interactive menu loop.
|
|
393
|
+
"""
|
|
394
|
+
enable_ansi_support()
|
|
395
|
+
scheduler = AlarmScheduler(on_trigger_callback=on_alarm_trigger)
|
|
396
|
+
scheduler.start()
|
|
397
|
+
|
|
398
|
+
TerminalUI.clear_screen()
|
|
399
|
+
TerminalUI.print_banner()
|
|
400
|
+
safe_print(f"{Colors.CYAN}Welcome to the CLI Alarm Clock! (Interactive Mode){Colors.RESET}")
|
|
401
|
+
TerminalUI.print_help()
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
while True:
|
|
405
|
+
now_str = datetime.datetime.now().strftime("%H:%M:%S")
|
|
406
|
+
prompt = f"({now_str}) alarm-clock > "
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
cmd_line = input(prompt).strip()
|
|
410
|
+
except EOFError:
|
|
411
|
+
break
|
|
412
|
+
|
|
413
|
+
if not cmd_line:
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
parts = cmd_line.split()
|
|
417
|
+
cmd = parts[0].lower()
|
|
418
|
+
args = parts[1:]
|
|
419
|
+
|
|
420
|
+
if cmd in ("exit", "quit"):
|
|
421
|
+
safe_print(f"\n{Colors.CYAN}Exiting Alarm Clock. Goodbye!{Colors.RESET}")
|
|
422
|
+
break
|
|
423
|
+
elif cmd == "help":
|
|
424
|
+
TerminalUI.print_help()
|
|
425
|
+
elif cmd == "add":
|
|
426
|
+
handle_add(scheduler, args)
|
|
427
|
+
elif cmd == "list":
|
|
428
|
+
handle_list(scheduler)
|
|
429
|
+
elif cmd == "remove":
|
|
430
|
+
handle_remove(scheduler, args)
|
|
431
|
+
elif cmd == "snooze":
|
|
432
|
+
handle_snooze(scheduler, args)
|
|
433
|
+
elif cmd == "dismiss":
|
|
434
|
+
handle_dismiss(scheduler, args)
|
|
435
|
+
elif cmd == "clear":
|
|
436
|
+
scheduler.clear_all_alarms()
|
|
437
|
+
safe_print(f"{Colors.GREEN}Success: Cleared all alarms and OS tasks.{Colors.RESET}")
|
|
438
|
+
else:
|
|
439
|
+
safe_print(f"{Colors.RED}Unknown command: '{cmd}'. Type 'help' for a list of commands.{Colors.RESET}")
|
|
440
|
+
|
|
441
|
+
except KeyboardInterrupt:
|
|
442
|
+
safe_print(f"\n\n{Colors.CYAN}Session interrupted. Exiting Alarm Clock. Goodbye!{Colors.RESET}")
|
|
443
|
+
finally:
|
|
444
|
+
scheduler.stop()
|
|
445
|
+
|
|
446
|
+
def main() -> None:
|
|
447
|
+
if len(sys.argv) > 1:
|
|
448
|
+
cmd = sys.argv[1].lower()
|
|
449
|
+
args = sys.argv[2:]
|
|
450
|
+
|
|
451
|
+
if cmd == "help":
|
|
452
|
+
print_cli_help()
|
|
453
|
+
elif cmd == "daemon":
|
|
454
|
+
run_daemon()
|
|
455
|
+
elif cmd == "ring":
|
|
456
|
+
if args:
|
|
457
|
+
try:
|
|
458
|
+
alarm_id = int(args[0])
|
|
459
|
+
run_ring(alarm_id)
|
|
460
|
+
except ValueError:
|
|
461
|
+
print("Error: Alarm ID must be an integer.")
|
|
462
|
+
sys.exit(1)
|
|
463
|
+
else:
|
|
464
|
+
print("Error: Ring command requires an Alarm ID.")
|
|
465
|
+
sys.exit(1)
|
|
466
|
+
elif cmd == "clear":
|
|
467
|
+
scheduler = AlarmScheduler()
|
|
468
|
+
scheduler.clear_all_alarms()
|
|
469
|
+
safe_print(f"{Colors.GREEN}Success: Cleared all alarms and OS tasks.{Colors.RESET}")
|
|
470
|
+
else:
|
|
471
|
+
scheduler = AlarmScheduler()
|
|
472
|
+
if cmd == "add":
|
|
473
|
+
handle_add(scheduler, args)
|
|
474
|
+
elif cmd == "list":
|
|
475
|
+
handle_list(scheduler)
|
|
476
|
+
elif cmd == "remove":
|
|
477
|
+
handle_remove(scheduler, args)
|
|
478
|
+
elif cmd == "snooze":
|
|
479
|
+
handle_snooze(scheduler, args)
|
|
480
|
+
elif cmd == "dismiss":
|
|
481
|
+
handle_dismiss(scheduler, args)
|
|
482
|
+
else:
|
|
483
|
+
safe_print(f"Unknown command: '{cmd}'. Type 'alarm-clock help' for usage.")
|
|
484
|
+
sys.exit(1)
|
|
485
|
+
else:
|
|
486
|
+
run_interactive()
|
|
487
|
+
|
|
488
|
+
if __name__ == "__main__":
|
|
489
|
+
main()
|
alarm_clock/logger.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
LOG_FILE_PATH = os.path.expanduser("~/.cli_alarms.log")
|
|
5
|
+
|
|
6
|
+
# Initialize the audit logger
|
|
7
|
+
logger = logging.getLogger("AlarmClockAudit")
|
|
8
|
+
logger.setLevel(logging.INFO)
|
|
9
|
+
|
|
10
|
+
# Prevent duplicate handlers on re-import
|
|
11
|
+
if not logger.handlers:
|
|
12
|
+
try:
|
|
13
|
+
# Configure file handler with UTF-8 encoding
|
|
14
|
+
file_handler = logging.FileHandler(LOG_FILE_PATH, encoding='utf-8')
|
|
15
|
+
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
|
16
|
+
file_handler.setFormatter(formatter)
|
|
17
|
+
logger.addHandler(file_handler)
|
|
18
|
+
except Exception:
|
|
19
|
+
# Fall back to console stream handler if file permissions are restricted
|
|
20
|
+
console_handler = logging.StreamHandler()
|
|
21
|
+
logger.addHandler(console_handler)
|
|
22
|
+
|
|
23
|
+
def audit_log(message: str, level: int = logging.INFO) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Appends an entry to the audit log.
|
|
26
|
+
Strips or replaces non-ascii characters to ensure log files remain completely safe.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
# Strip or replace non-ascii if needed, but logging handle with utf-8 should be safe
|
|
30
|
+
logger.log(level, message)
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|