Files
wecker/README.md
T
gurix 3a7b6ac7a3 refactor: styles own their ringing tone directly (Pattern C)
Drop the injected AlarmSound Protocol and the wecker.Sound class. Each style
now owns its tone by using pygame directly; the runner owns only the audio
engine lifecycle (mixer init/quit), button sampling, and cleanup. This is the
seam for future styles that handle their own tone — a 'talk' style would just
import pygame and play speech, with no shared interface to extend.

- styles/base.py: AlarmStyle.__init__(set_led, music_file) + start() + update()
- styles/blink.py: start() loads+plays music via pygame.mixer.music
- styles/simple.py: owns its beep — synthesises a square-wave buffer in
  module (stdlib array+math) and plays it via pygame.mixer.Sound
- wecker.py: setup() brings up the mixer only; run_alarm constructs the style
  and guards start() with a clean log+exit on failure

Tests: a shared tests/conftest.py stubs RPi.GPIO/pygame in sys.modules before
any SUT import (order-independent, removes duplicated inline mocking); the
wecker mock_pygame fixture patches one fresh pygame mock into wecker + both
style modules so assertions see the same calls.

README: tone-ownership and plugin-contract updated.
2026-08-02 21:50:00 +02:00

193 lines
7.9 KiB
Markdown

# Arcade Button Alarm Clock
A unique Raspberry Pi-based alarm clock that makes sure you are fully awake before it turns off!
Instead of simply pressing a button to stop the alarm, this clock requires you to solve a short memory and attention puzzle. When the alarm rings, you press the button, and the built-in LED will blink a random number of times (between 1 and 7). You then have to press the button exactly that many times to confirm. Get it right, and the music stops. Get it wrong, and you'll have to try again!
## Hardware Requirements
* **Raspberry Pi 4 Model B** (2018)
* **33mm Illuminated Arcade Button** (e.g., from bastelgarage.ch)
* **External Speakers** (connected via 3.5mm jack or a USB Soundcard for better audio quality)
## Software Requirements
This project uses Python 3 and `uv` for dependency management. To set up the environment:
1. Install `uv` if you haven't already:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
2. The project contains a `pyproject.toml` file with all dependencies.
```bash
uv sync
```
## Wiring
Please refer to the [arcade-button-wiring.md](arcade-button-wiring.md) file for detailed instructions on how to connect the arcade button and its LED to the Raspberry Pi's GPIO pins.
## Usage
1. Make sure your audio file (`Laid Back - Sunshine Reggae.mp3` or any other MP3 you prefer) is in the project directory, or set a custom path.
2. Run the alarm clock script manually:
```bash
# Use the default music file or the MUSIC_FILE environment variable
python3 wecker.py
# Or pass a custom music file (used by the blink style; ignored by simple)
python3 wecker.py --music-file /path/to/your/alarm.mp3
# Choose an alarm style (default: blink; see Alarm Styles below)
python3 wecker.py --style simple
```
`--style` selects the alarm behaviour and defaults to `blink` (so existing cron entries keep working unchanged). Each style owns its own ringing tone: `blink` plays a music file on loop (resolved from `--music-file`, then the `MUSIC_FILE` env var, then the default `Laid Back - Sunshine Reggae.mp3`); `simple` ignores the music file and beeps.
## Automating and Managing Alarms (GraphQL API)
Instead of manually editing your crontab, this project provides a simple GraphQL API to List, Get, Set, and Delete your alarms.
### API Setup & Authentication
1. Create a `.env` file in the project root to set up your Static API Key:
```bash
cp .env.example .env
# Edit .env and set your own secure API_KEY
```
2. The API is managed as a `systemd` user service and starts automatically on boot. To manage it manually, you can use:
```bash
systemctl --user status wecker-api.service
systemctl --user restart wecker-api.service
```
*(If you want to run it manually instead: `uv run uvicorn api.main:app --host 0.0.0.0 --port 8000`)*
3. The API will be available at `http://<YOUR_PI_IP>:8000/graphql` (For Tailscale users: `http://100.88.83.57:8000/graphql`).
**Authentication:** All requests to the `/graphql` endpoint require a custom header:
`X-API-Key: <YOUR_API_KEY>`
### Security Considerations
* **Keep the API key secret.** Treat it like a password: store it only in `.env`, rotate it periodically, and generate a strong key with `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`.
* **The API currently does not implement rate limiting.** The safest deployment is to expose it only on your local network or through Tailscale. If you expose it to the internet, place it behind a reverse proxy (e.g., nginx, Caddy, or Traefik) that handles TLS and brute-force protection.
* If you need built-in rate limiting, consider adding `slowapi` or a similar ASGI middleware later.
### Example API Usage
**Check if alarm is currently ringing:**
```graphql
query {
isRinging
}
```
Returns `true` if the wecker alarm clock is currently active (playing music), `false` otherwise.
**Get all alarms:**
```graphql
query {
getAlarms {
id
cronExpression
isEnabled
style
}
}
```
**Set an alarm:**
```graphql
mutation {
setAlarm(
cronExpression: "45 6 * * 1-5",
isEnabled: true,
style: "simple"
) {
id
cronExpression
isEnabled
style
}
}
```
`style` is optional and defaults to `"simple"` (see [Alarm Styles](#alarm-styles)).
*(Note: `command` is generated automatically by the API and is **not** accepted as an input parameter on `setAlarm`, to prevent command injection into the system crontab.)*
**Delete an alarm:**
```graphql
mutation {
deleteAlarm(id: "YOUR-ALARM-ID")
}
```
**Start the alarm immediately:**
```graphql
mutation {
startRinging(style: "blink")
}
```
Returns `true` if the alarm started, `false` if it was already ringing (ignored). `style` is optional and defaults to `"simple"`.
**Stop the alarm immediately:**
```graphql
mutation {
stopRinging
}
```
Returns `true` if the alarm was stopped, `false` if it wasn't ringing.
### Example response for `isRinging`:
```json
{
"data": {
"isRinging": true
}
}
```
## Alarm Styles
Each alarm has a **style** that decides how you turn it off. Set it per alarm via the `style` field of `setAlarm` (or the `style` argument of `startRinging`). Available styles:
| Style | Ringing tone | How to stop |
|---|---|---|
| `simple` | A repeating, ordinary-alarm-clock **beep** (no music file needed). | Press the button once. The LED stays solid on so the button is findable in the dark. **Default for new alarms.** |
| `blink` | A music file on an endless loop (from `--music-file` / `MUSIC_FILE` / the default track). | The memory-and-attention puzzle described in [How the Puzzle Works](#how-the-puzzle-works). |
**The style owns its ringing tone.** The runner brings up the audio engine (the pygame mixer) and hands each style the button + LED; the style then produces its own tone directly — `blink` plays a music file, `simple` synthesises a beep, a future `talk` style would play speech. Styles use pygame directly (it's the audio engine, not hardware); the runner still owns mixer init and cleanup. This direct ownership is the seam for future styles that handle their own tone.
Adding a style is a plugin-style drop-in: add a class under `styles/` implementing the `AlarmStyle` contract (`__init__(set_led, music_file)` + `start()` + `update(now, is_pressed) -> bool`) and register one line in `styles/__init__.py`.
**Backward compatibility:** existing cron entries created before this feature have no `--style` flag and keep running the `blink` puzzle, so an upgrade never silently changes an alarm. To switch an existing alarm to `simple`, re-save it with `setAlarm(id: ..., cronExpression: ..., style: "simple")`.
## How the Puzzle Works
1. **Ringing:** The music plays in an endless loop.
2. **Start:** Press the arcade button once to start the puzzle. Wait 3 seconds.
3. **Observe:** The arcade button's LED will blink between 1 and 7 times.
4. **Input:** Press the button the exact number of times the LED blinked. Every press is confirmed by the LED lighting up.
5. **Wait:** Stop pressing for 3 seconds to lock in your answer.
6. **Evaluation:**
* *Correct:* The music stops and the script exits.
* *Incorrect:* The alarm waits a few seconds and generates a completely new sequence for you to solve.
## Troubleshooting
**Audio issues when running via Cron (ALSA: Couldn't open audio device: Unknown error 524):**
If the script runs perfectly from the terminal but crashes when triggered by cron, it might be trying to use an invalid audio output (like a disconnected HDMI port) because background jobs don't have the same environment variables as interactive sessions.
To force the system to use the 3.5mm headphone jack (Audio Jack), you can create a `~/.asoundrc` file for the `pi` user with the following content:
```
pcm.!default {
type asym
playback.pcm {
type plug
slave.pcm "hw:2,0"
}
}
ctl.!default {
type hw
card 2
}
```
*(Note: Change `hw:2,0` and `card 2` to match your actual headphone jack card index, which you can find by running `aplay -l`).*
## Author
Markus Graf (info@marksugraf.ch)