Files
wecker/api/schema.py
T

187 lines
5.4 KiB
Python
Raw Normal View History

import strawberry
from typing import List, Optional
import os
import sys
import time
import subprocess
import signal
from pathlib import Path
from api.crontab_manager import CrontabManager
from common import PID_FILE
def get_manager():
tabfile = os.getenv("TABFILE")
return CrontabManager(tabfile=tabfile)
def is_wecker_ringing() -> bool:
"""Check if the wecker process is currently running via the PID file."""
if not os.path.exists(PID_FILE):
return False
try:
with open(PID_FILE) as f:
pid = int(f.read().strip())
os.kill(pid, 0)
# Guard against PID reuse: verify the running process is actually wecker.py.
cmdline_path = f"/proc/{pid}/cmdline"
with open(cmdline_path, "rb") as f:
cmdline = f.read().replace(b"\0", b" ").decode()
return "wecker.py" in cmdline
except (ValueError, ProcessLookupError, PermissionError, OSError, FileNotFoundError):
return False
2026-06-18 16:38:58 +02:00
def _project_root() -> Path:
"""Locate the project root by searching upward for pyproject.toml."""
start = Path(__file__).resolve()
for parent in start.parents:
if (parent / "pyproject.toml").exists():
return parent
# Fallback for unusual layouts; keep a sensible default.
return start.parent.parent
2026-06-18 16:38:58 +02:00
def _default_command() -> str:
2026-06-18 16:38:58 +02:00
"""Return the default shell command to run wecker.py from crontab."""
project_root = _project_root()
python_exec = sys.executable
runtime_dir = f"/run/user/{os.getuid()}"
return (
f"cd {project_root} && "
f"XDG_RUNTIME_DIR={runtime_dir} SDL_AUDIODRIVER=pulse "
f"{python_exec} wecker.py >> wecker.log 2>&1"
)
2026-06-18 16:38:58 +02:00
def _start_wecker_process() -> subprocess.Popen:
"""Start wecker.py without invoking a shell."""
project_root = _project_root()
env = os.environ.copy()
env.setdefault("SDL_AUDIODRIVER", "pulse")
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
2026-06-18 16:38:58 +02:00
return subprocess.Popen(
[sys.executable, str(project_root / "wecker.py")],
cwd=project_root,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
env=env,
2026-06-18 16:38:58 +02:00
)
@strawberry.type
class Alarm:
id: str
cron_expression: str
is_enabled: bool
def _alarm_from_dict(data: dict) -> Alarm:
"""Map a CrontabManager alarm dict to the public GraphQL Alarm type."""
return Alarm(
id=data["id"],
cron_expression=data["cron_expression"],
is_enabled=data["is_enabled"],
)
def _load_alarms() -> List[Alarm]:
"""Load all alarms from the crontab and map them to GraphQL types."""
manager = get_manager()
return [_alarm_from_dict(a) for a in manager.get_alarms()]
@strawberry.type
class Query:
@strawberry.field
def is_ringing(self) -> bool:
return is_wecker_ringing()
@strawberry.field
def get_alarms(self) -> List[Alarm]:
return _load_alarms()
@strawberry.field
def get_alarm(self, id: str) -> Optional[Alarm]:
for alarm in _load_alarms():
if alarm.id == id:
return alarm
return None
@strawberry.type
class Mutation:
@strawberry.field
def set_alarm(
self,
cron_expression: str,
is_enabled: bool = True,
id: Optional[str] = None,
) -> Alarm:
command = _default_command()
manager = get_manager()
new_id = manager.set_alarm(
alarm_id=id,
cron_expression=cron_expression,
command=command,
is_enabled=is_enabled,
)
return Alarm(
id=new_id,
cron_expression=cron_expression,
is_enabled=is_enabled,
)
@strawberry.field
def delete_alarm(self, id: str) -> bool:
manager = get_manager()
# Verify it exists
alarms = manager.get_alarms()
exists = any(a["id"] == id for a in alarms)
if exists:
manager.delete_alarm(id)
return True
return False
@strawberry.field
def start_ringing(self) -> bool:
"""Start the wecker alarm if it is not already ringing.
Returns True if started, False if already ringing."""
if is_wecker_ringing():
return False
2026-06-18 16:38:58 +02:00
_start_wecker_process()
return True
@strawberry.field
def stop_ringing(self) -> bool:
"""Stop the wecker alarm if it is currently ringing.
Returns True if stopped, False if not ringing."""
if not is_wecker_ringing():
return False
try:
with open(PID_FILE) as f:
pid = int(f.read().strip())
os.kill(pid, signal.SIGTERM)
# Give the process a short grace period, then escalate to SIGKILL.
for _ in range(20):
time.sleep(0.1)
try:
os.kill(pid, 0)
except ProcessLookupError:
break
else:
os.kill(pid, signal.SIGKILL)
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
return True
except (ValueError, ProcessLookupError, PermissionError, OSError):
# PID file is stale or process already dead
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
return False
schema = strawberry.Schema(query=Query, mutation=Mutation)