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.
This commit is contained in:
2026-07-31 16:20:16 +02:00
parent b3f3db6f13
commit c50f296232
4 changed files with 177 additions and 7 deletions
+22
View File
@@ -1,6 +1,9 @@
import shlex
import uuid import uuid
from crontab import CronTab, CronSlices from crontab import CronTab, CronSlices
from styles import LEGACY_STYLE
class CrontabManager: class CrontabManager:
COMMENT_PREFIX = "wecker-alarm:" COMMENT_PREFIX = "wecker-alarm:"
@@ -28,6 +31,7 @@ class CrontabManager:
"cron_expression": str(job.slices), "cron_expression": str(job.slices),
"command": job.command, "command": job.command,
"is_enabled": job.is_enabled(), "is_enabled": job.is_enabled(),
"style": _parse_style(job.command),
} }
) )
return alarms return alarms
@@ -64,3 +68,21 @@ class CrontabManager:
comment = f"{self.COMMENT_PREFIX}{alarm_id}" comment = f"{self.COMMENT_PREFIX}{alarm_id}"
cron.remove_all(comment=comment) cron.remove_all(comment=comment)
cron.write() cron.write()
def _parse_style(command: str) -> str:
"""Extract the --style value from a cron command.
Returns LEGACY_STYLE ('blink') for entries that predate --style, so an
upgrade never silently changes an existing alarm's behaviour.
"""
try:
tokens = shlex.split(command)
except ValueError:
return LEGACY_STYLE
for i, tok in enumerate(tokens):
if tok == "--style" and i + 1 < len(tokens):
return tokens[i + 1]
if tok.startswith("--style="):
return tok.split("=", 1)[1]
return LEGACY_STYLE
+15 -7
View File
@@ -1,6 +1,7 @@
import strawberry import strawberry
from typing import List, Optional from typing import List, Optional
import os import os
import shlex
import sys import sys
import time import time
import subprocess import subprocess
@@ -8,6 +9,7 @@ import signal
from pathlib import Path from pathlib import Path
from api.crontab_manager import CrontabManager from api.crontab_manager import CrontabManager
from common import PID_FILE from common import PID_FILE
from styles import get_style
def get_manager(): def get_manager():
@@ -42,7 +44,7 @@ def _project_root() -> Path:
return start.parent.parent return start.parent.parent
def _default_command() -> str: def _default_command(style: str) -> str:
"""Return the default shell command to run wecker.py from crontab.""" """Return the default shell command to run wecker.py from crontab."""
project_root = _project_root() project_root = _project_root()
python_exec = sys.executable python_exec = sys.executable
@@ -50,18 +52,18 @@ def _default_command() -> str:
return ( return (
f"cd {project_root} && " f"cd {project_root} && "
f"XDG_RUNTIME_DIR={runtime_dir} SDL_AUDIODRIVER=pulse " f"XDG_RUNTIME_DIR={runtime_dir} SDL_AUDIODRIVER=pulse "
f"{python_exec} wecker.py >> wecker.log 2>&1" f"{python_exec} wecker.py --style {shlex.quote(style)} >> wecker.log 2>&1"
) )
def _start_wecker_process() -> subprocess.Popen: def _start_wecker_process(style: str) -> subprocess.Popen:
"""Start wecker.py without invoking a shell.""" """Start wecker.py without invoking a shell."""
project_root = _project_root() project_root = _project_root()
env = os.environ.copy() env = os.environ.copy()
env.setdefault("SDL_AUDIODRIVER", "pulse") env.setdefault("SDL_AUDIODRIVER", "pulse")
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
return subprocess.Popen( return subprocess.Popen(
[sys.executable, str(project_root / "wecker.py")], [sys.executable, str(project_root / "wecker.py"), "--style", style],
cwd=project_root, cwd=project_root,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@@ -75,6 +77,7 @@ class Alarm:
id: str id: str
cron_expression: str cron_expression: str
is_enabled: bool is_enabled: bool
style: str
def _alarm_from_dict(data: dict) -> Alarm: def _alarm_from_dict(data: dict) -> Alarm:
@@ -83,6 +86,7 @@ def _alarm_from_dict(data: dict) -> Alarm:
id=data["id"], id=data["id"],
cron_expression=data["cron_expression"], cron_expression=data["cron_expression"],
is_enabled=data["is_enabled"], is_enabled=data["is_enabled"],
style=data["style"],
) )
@@ -118,8 +122,10 @@ class Mutation:
cron_expression: str, cron_expression: str,
is_enabled: bool = True, is_enabled: bool = True,
id: Optional[str] = None, id: Optional[str] = None,
style: str = "simple",
) -> Alarm: ) -> Alarm:
command = _default_command() get_style(style) # validate; raises ValueError on unknown style
command = _default_command(style)
manager = get_manager() manager = get_manager()
new_id = manager.set_alarm( new_id = manager.set_alarm(
@@ -132,6 +138,7 @@ class Mutation:
id=new_id, id=new_id,
cron_expression=cron_expression, cron_expression=cron_expression,
is_enabled=is_enabled, is_enabled=is_enabled,
style=style,
) )
@strawberry.field @strawberry.field
@@ -146,12 +153,13 @@ class Mutation:
return False return False
@strawberry.field @strawberry.field
def start_ringing(self) -> bool: def start_ringing(self, style: str = "simple") -> bool:
"""Start the wecker alarm if it is not already ringing. """Start the wecker alarm if it is not already ringing.
Returns True if started, False if already ringing.""" Returns True if started, False if already ringing."""
if is_wecker_ringing(): if is_wecker_ringing():
return False return False
_start_wecker_process() get_style(style) # validate; raises ValueError on unknown style
_start_wecker_process(style)
return True return True
@strawberry.field @strawberry.field
+105
View File
@@ -2,6 +2,7 @@ from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import tempfile import tempfile
import os import os
import pytest
import signal import signal
# We need to set the environment variable before importing the app # We need to set the environment variable before importing the app
@@ -403,3 +404,107 @@ def test_pid_file_same_shared_constant_in_api():
assert api_schema.PID_FILE is common.PID_FILE, ( assert api_schema.PID_FILE is common.PID_FILE, (
"api.schema.PID_FILE must reference common.PID_FILE, not redefine it" "api.schema.PID_FILE must reference common.PID_FILE, not redefine it"
) )
def _cleanup_alarm(alarm_id):
headers = {"X-API-Key": "test-secret"}
client.post(
"/graphql",
json={"query": f'mutation {{ deleteAlarm(id: "{alarm_id}") }}'},
headers=headers,
)
def test_set_alarm_accepts_and_returns_style():
headers = {"X-API-Key": "test-secret"}
mutation = '''
mutation {
setAlarm(cronExpression: "0 9 * * *", style: "blink") {
id
style
}
}
'''
res = client.post("/graphql", json={"query": mutation}, headers=headers)
assert res.status_code == 200
alarm = res.json()["data"]["setAlarm"]
assert alarm["style"] == "blink"
_cleanup_alarm(alarm["id"])
def test_set_alarm_default_style_is_simple():
headers = {"X-API-Key": "test-secret"}
mutation = '''
mutation {
setAlarm(cronExpression: "0 9 * * *") { id style }
}
'''
res = client.post("/graphql", json={"query": mutation}, headers=headers)
assert res.status_code == 200
alarm = res.json()["data"]["setAlarm"]
assert alarm["style"] == "simple"
_cleanup_alarm(alarm["id"])
def test_set_alarm_rejects_unknown_style():
headers = {"X-API-Key": "test-secret"}
mutation = '''
mutation {
setAlarm(cronExpression: "0 9 * * *", style: "nope") { id }
}
'''
res = client.post("/graphql", json={"query": mutation}, headers=headers)
assert res.status_code == 200
assert "errors" in res.json()
assert "Unknown alarm style" in str(res.json()["errors"])
def test_get_alarms_includes_style():
headers = {"X-API-Key": "test-secret"}
set_mut = '''
mutation { setAlarm(cronExpression: "0 9 * * *", style: "blink") { id } }
'''
res = client.post("/graphql", json={"query": set_mut}, headers=headers)
alarm_id = res.json()["data"]["setAlarm"]["id"]
res = client.post(
"/graphql", json={"query": "{ getAlarms { id style } }"}, headers=headers
)
alarms = res.json()["data"]["getAlarms"]
assert any(a["id"] == alarm_id and a["style"] == "blink" for a in alarms)
_cleanup_alarm(alarm_id)
def test_start_ringing_passes_style():
from api.schema import Mutation
mutation = Mutation()
with patch("api.schema.is_wecker_ringing", return_value=False), \
patch("subprocess.Popen") as mock_popen:
mock_popen.return_value.pid = 9999
assert mutation.start_ringing(style="blink") is True
argv = mock_popen.call_args[0][0]
assert "--style" in argv and "blink" in argv
def test_start_ringing_default_style_is_simple():
from api.schema import Mutation
mutation = Mutation()
with patch("api.schema.is_wecker_ringing", return_value=False), \
patch("subprocess.Popen") as mock_popen:
mock_popen.return_value.pid = 9999
mutation.start_ringing()
argv = mock_popen.call_args[0][0]
assert "simple" in argv
def test_start_ringing_rejects_unknown_style():
from api.schema import Mutation
mutation = Mutation()
with patch("api.schema.is_wecker_ringing", return_value=False), \
patch("subprocess.Popen") as mock_popen:
with pytest.raises(ValueError, match="Unknown alarm style"):
mutation.start_ringing(style="nope")
mock_popen.assert_not_called()
+35
View File
@@ -72,3 +72,38 @@ def test_set_alarm_rejects_invalid_cron_expression(crontab_file):
manager = CrontabManager(tabfile=crontab_file) manager = CrontabManager(tabfile=crontab_file)
with pytest.raises(ValueError, match="Invalid cron expression"): with pytest.raises(ValueError, match="Invalid cron expression"):
manager.set_alarm("test-id", "not-a-cron-expression", "cmd", True) manager.set_alarm("test-id", "not-a-cron-expression", "cmd", True)
def test_get_alarms_parses_style(crontab_file):
manager = CrontabManager(tabfile=crontab_file)
manager.set_alarm(
alarm_id="style-id",
cron_expression="30 7 * * *",
command="python wecker.py --style simple",
is_enabled=True,
)
alarms = manager.get_alarms()
assert len(alarms) == 1
assert alarms[0]["style"] == "simple"
def test_get_alarms_parses_eq_style(crontab_file):
manager = CrontabManager(tabfile=crontab_file)
manager.set_alarm(
alarm_id="style-eq-id",
cron_expression="30 7 * * *",
command="python wecker.py --style=blink",
is_enabled=True,
)
assert manager.get_alarms()[0]["style"] == "blink"
def test_get_alarms_legacy_command_defaults_to_blink(crontab_file):
manager = CrontabManager(tabfile=crontab_file)
manager.set_alarm(
alarm_id="legacy-id",
cron_expression="30 7 * * *",
command="python wecker.py",
is_enabled=True,
)
assert manager.get_alarms()[0]["style"] == "blink"