Files
wecker/api/schema.py
T
gurix c50f296232 feat: pass alarm style through crontab manager and GraphQL API
- crontab_manager.get_alarms now parses --style from the cron command
  (defaults to 'blink' for legacy entries without --style)
- GraphQL Alarm type gains a 'style' field
- setAlarm accepts a 'style' arg (default 'simple') and validates it
  against the styles registry; unknown styles raise a GraphQL error
- _default_command embeds --style <name> into the cron command
- startRinging accepts 'style' (default 'simple') and passes --style to
  the spawned wecker.py

The crontab remains the single source of truth; the style simply rides
in the cron command alongside --music-file.
2026-07-31 16:20:16 +02:00

195 lines
5.7 KiB
Python

import strawberry
from typing import List, Optional
import os
import shlex
import sys
import time
import subprocess
import signal
from pathlib import Path
from api.crontab_manager import CrontabManager
from common import PID_FILE
from styles import get_style
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
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
def _default_command(style: str) -> str:
"""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 --style {shlex.quote(style)} >> wecker.log 2>&1"
)
def _start_wecker_process(style: str) -> 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()}")
return subprocess.Popen(
[sys.executable, str(project_root / "wecker.py"), "--style", style],
cwd=project_root,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
env=env,
)
@strawberry.type
class Alarm:
id: str
cron_expression: str
is_enabled: bool
style: str
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"],
style=data["style"],
)
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,
style: str = "simple",
) -> Alarm:
get_style(style) # validate; raises ValueError on unknown style
command = _default_command(style)
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,
style=style,
)
@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, style: str = "simple") -> 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
get_style(style) # validate; raises ValueError on unknown style
_start_wecker_process(style)
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)