Fire alarm daily, harden dismissal challenge; ignore wake_state.json

- _constants.py: ALARM_DAYS now every day (was Mon/Fri/Sat/Sun);
  DISMISS_CODE_LENGTH 8->10 and DISMISS_ROUNDS_REQUIRED 2->4 so
  dismissing forces staying awake longer.
- _challenges.py / _alarm.py: expanded dismissal challenge logic, with
  matching test updates.
- shutdown-wrapper.sh: updated hibernate/rtcwake wrapper.
- .gitignore: restore the missing rule for wake_alarm/wake_state.json
  (HMAC dismissal runtime state — must never be tracked, per CLAUDE.md).

Tests: 191 passed, 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
This commit is contained in:
Krzysztof Rudnicki 2026-07-10 23:14:40 +02:00
parent 643c55e204
commit 32d0994ba6
7 changed files with 309 additions and 51 deletions

4
.gitignore vendored
View File

@ -14,3 +14,7 @@ coverage.lcov
htmlcov/
*.log
.DS_Store
# Runtime state: HMAC-signed dismissal record for the current alarm day.
# Never tracked (see CLAUDE.md).
wake_alarm/wake_state.json

View File

@ -1,17 +1,24 @@
#!/bin/bash
# Wrapper for /usr/bin/shutdown that redirects to rtcwake -m disk on alarm
# nights (Mon, Fri, Sat, Sun by tomorrow's day-of-week). This ensures that
# both the automated systemd timer AND manual "shutdown now" hibernate
# correctly so the PC wakes for the morning alarm.
# nights (every day of the week). This ensures that both the automated
# systemd timer AND manual "shutdown now" hibernate correctly so the PC
# wakes for the morning alarm.
#
# Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown
# because /usr/local/bin appears first in PATH).
#
# Wake time is taken from the private GitHub Gist written by the phone app
# (see phone_app/). Falls back to WAKE_AFTER_HOURS if the Gist is not
# configured or unreachable. Config: ~/.config/wake_alarm/gist_sync.json
# with keys "token" (GitHub PAT with gist scope) and "gist_id".
set -euo pipefail
REAL_SHUTDOWN=/usr/bin/shutdown
RTCWAKE=/usr/sbin/rtcwake
WAKE_AFTER_HOURS=8 # Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py
# Fallback: how many hours after shutdown to wake when Gist is unavailable.
# Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py.
WAKE_AFTER_HOURS=8
# Pass through reboots and cancel commands unchanged.
for arg in "$@"; do
@ -22,13 +29,49 @@ for arg in "$@"; do
esac
done
# Check if tomorrow is an alarm day (Mon=1, Fri=5, Sat=6, Sun=7 in date +%u).
# Read alarm time from the private GitHub Gist written by the phone app.
# Returns the Unix epoch (seconds) of the next occurrence of the stored H:M.
# Exits non-zero if the Gist is not configured, unreachable, or malformed.
get_gist_alarm_epoch() {
local cfg="$HOME/.config/wake_alarm/gist_sync.json"
[[ -f "$cfg" ]] || return 1
local token gist_id raw_content hour minute target_epoch now_epoch
token=$(jq -r .token "$cfg" 2>/dev/null) || return 1
gist_id=$(jq -r .gist_id "$cfg" 2>/dev/null) || return 1
[[ -n "$token" && "$token" != "null" ]] || return 1
[[ -n "$gist_id" && "$gist_id" != "null" ]] || return 1
raw_content=$(curl -sf --max-time 10 \
-H "Authorization: token $token" \
"https://api.github.com/gists/$gist_id" \
| jq -r '.files["wake_alarm_sync.json"].content') || return 1
[[ -n "$raw_content" && "$raw_content" != "null" ]] || return 1
hour=$(printf '%s' "$raw_content" | jq -r .alarm_hour 2>/dev/null) || return 1
minute=$(printf '%s' "$raw_content" | jq -r .alarm_minute 2>/dev/null) || return 1
[[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1
printf -v now_epoch '%(%s)T' -1
target_epoch=$(date -d "today $hour:$minute" +%s)
if (( target_epoch <= now_epoch )); then
target_epoch=$(date -d "tomorrow $hour:$minute" +%s)
fi
echo "$target_epoch"
}
# Every day of the week is an alarm day (date +%u: Mon=1 … Sun=7).
tomorrow_dow=$(date -d "tomorrow" +%u)
case "$tomorrow_dow" in
1|5|6|7)
wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 ))
logger -t shutdown-wrapper \
"Tomorrow is alarm day (dow=$tomorrow_dow) — hibernating, RTC wake at epoch $wake_epoch"
1|2|3|4|5|6|7)
if wake_epoch=$(get_gist_alarm_epoch 2>/dev/null); then
logger -t shutdown-wrapper \
"Gist sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
else
wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 ))
logger -t shutdown-wrapper \
"Gist unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
fi
sudo "$RTCWAKE" -m no -t "$wake_epoch"
exec /usr/bin/systemctl hibernate
;;

View File

@ -191,8 +191,10 @@ class WakeAlarm:
)
info_label.pack(pady=10)
# Math and sort use a smaller font because their display text is wider.
code_font_size = 48 if challenge.kind in ("math", "sort") else 72
# Non-flash types use a smaller font because their display text is wider.
code_font_size = (
48 if challenge.kind in ("math", "sort", "sequence", "reverse") else 72
)
code_label = tk.Label(
container,
text=challenge.display,

View File

@ -1,9 +1,11 @@
"""Dismiss-challenge types for the wake alarm.
Provides three challenge variants:
- math: solve an arithmetic problem
Provides five challenge variants:
- math: solve an arithmetic problem (addition, subtraction, multiplication, division)
- sort: type shuffled digits in ascending order
- flash: memorise a code before it is hidden
- sequence: identify and continue a numeric pattern
- reverse: type a displayed word backwards
"""
from __future__ import annotations
@ -17,6 +19,25 @@ from wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS
# legible at a glance, even half-asleep.
_DISMISS_CHARS: str = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
# Words used by the reverse challenge — common, unambiguous when read half-asleep.
_REVERSE_WORDS: tuple[str, ...] = (
"MORNING",
"COFFEE",
"PILLOW",
"BLANKET",
"ALARM",
"AWAKE",
"SHOWER",
"BREAKFAST",
"WORKOUT",
"WINDOW",
"MONDAY",
"TUESDAY",
"FRIDAY",
"SUNDAY",
"WEEKEND",
)
class _Challenge:
"""A dismiss challenge presented to the user to prove wakefulness."""
@ -32,7 +53,7 @@ class _Challenge:
"""Store challenge parameters.
Args:
kind: Challenge type — "math", "flash", or "sort".
kind: Challenge type — "math", "flash", "sort", "sequence", or "reverse".
display: Text shown in the large code label.
answer: Expected typed answer (normalised, upper-case).
hint: Short instruction shown above the code label.
@ -55,12 +76,14 @@ def _generate_code() -> str:
def _make_math_challenge() -> _Challenge:
"""Generate an arithmetic problem the user must solve to dismiss.
Picks randomly from addition, subtraction, and multiplication.
The user types only the numeric answer — no copying, no autopilot.
Picks randomly from addition, subtraction, multiplication, and division.
Addition and subtraction use 3-digit operands for extra difficulty.
Division always produces an integer result.
"""
op = secrets.choice(("+", "-", "*"))
op = secrets.choice(("+", "-", "*", "÷"))
if op == "+":
a, b = 10 + secrets.randbelow(90), 10 + secrets.randbelow(90)
a = 100 + secrets.randbelow(400)
b = 100 + secrets.randbelow(400)
return _Challenge(
kind="math",
display=f"{a} + {b} = ?",
@ -68,19 +91,30 @@ def _make_math_challenge() -> _Challenge:
hint="Solve and type the answer",
)
if op == "-":
a = 20 + secrets.randbelow(80)
b = 10 + secrets.randbelow(a - 10)
a = 200 + secrets.randbelow(300)
b = 100 + secrets.randbelow(a - 100)
return _Challenge(
kind="math",
display=f"{a} - {b} = ?",
answer=str(a - b),
hint="Solve and type the answer",
)
a, b = 12 + secrets.randbelow(14), 3 + secrets.randbelow(7)
if op == "*":
a = 12 + secrets.randbelow(14)
b = 3 + secrets.randbelow(7)
return _Challenge(
kind="math",
display=f"{a} * {b} = ?",
answer=str(a * b),
hint="Solve and type the answer",
)
# Division: construct a * b first so the quotient is always an integer.
b = 3 + secrets.randbelow(8) # divisor: 3-10
a = (10 + secrets.randbelow(20)) * b # dividend: b * (10-29)
return _Challenge(
kind="math",
display=f"{a} * {b} = ?",
answer=str(a * b),
display=f"{a} ÷ {b} = ?",
answer=str(a // b),
hint="Solve and type the answer",
)
@ -88,15 +122,14 @@ def _make_math_challenge() -> _Challenge:
def _make_sort_challenge() -> _Challenge:
"""Generate a sort-the-digits challenge.
Displays six shuffled single digits; the user types them ascending (no spaces).
Requires a brief cognitive effort — fast enough to be fair, slow enough to prove
you are awake.
Displays eight shuffled single digits; the user types them ascending (no spaces).
Eight digits (up from six) require more effort to sort mentally.
"""
pool = list(range(1, 10))
for i in range(len(pool) - 1, 0, -1):
j = secrets.randbelow(i + 1)
pool[i], pool[j] = pool[j], pool[i]
digits = pool[:6]
digits = pool[:8]
display = " ".join(str(d) for d in digits)
answer = "".join(str(d) for d in sorted(digits))
return _Challenge(
@ -122,8 +155,62 @@ def _make_flash_challenge() -> _Challenge:
)
def _make_sequence_challenge() -> _Challenge:
"""Generate a numeric-pattern completion challenge.
Shows four terms of an arithmetic or geometric sequence and asks for the fifth.
Forces the user to identify the pattern before they can answer.
"""
if secrets.randbelow(2) == 0:
# Arithmetic sequence (linear)
start = 2 + secrets.randbelow(14) # start: 2-15
step = 2 + secrets.randbelow(8) # step: 2-9
terms = [start + step * i for i in range(4)]
answer = start + step * 4
display = ", ".join(str(t) for t in terms) + ", ?"
return _Challenge(
kind="sequence",
display=display,
answer=str(answer),
hint="Find the pattern and type the next number",
)
# Geometric sequence (exponential)
start = 2 + secrets.randbelow(5) # start: 2-6
ratio = 2 + secrets.randbelow(2) # ratio: 2 or 3
terms = [start * (ratio**i) for i in range(4)]
answer = start * (ratio**4)
display = ", ".join(str(t) for t in terms) + ", ?"
return _Challenge(
kind="sequence",
display=display,
answer=str(answer),
hint="Find the pattern and type the next number",
)
def _make_reverse_challenge() -> _Challenge:
"""Generate a reverse-the-word challenge.
Picks a common word and asks the user to type it backwards.
Simple but demands conscious attention — autopilot types forward.
"""
word = secrets.choice(_REVERSE_WORDS)
return _Challenge(
kind="reverse",
display=f"Type this word backwards:\n{word}",
answer=word[::-1],
hint="Type the word backwards",
)
def _make_challenge() -> _Challenge:
"""Pick a random challenge type and generate an instance."""
return secrets.choice(
(_make_math_challenge, _make_flash_challenge, _make_sort_challenge),
(
_make_math_challenge,
_make_flash_challenge,
_make_sort_challenge,
_make_sequence_challenge,
_make_reverse_challenge,
),
)()

View File

@ -5,8 +5,8 @@ from __future__ import annotations
from pathlib import Path
# Days the wake alarm is active (Python weekday(): Mon=0 ... Sun=6)
# Monday, Friday, Saturday, Sunday
ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6})
# Every day of the week.
ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6})
# How many hours after shutdown the PC should wake
WAKE_AFTER_HOURS: int = 8
@ -28,11 +28,11 @@ MEDIUM_BEEP_INTERVAL: float = 5.0
LOUD_TOGGLE_INTERVAL: float = 2.0
# Dismiss challenge: length of the random code
DISMISS_CODE_LENGTH: int = 8
DISMISS_CODE_LENGTH: int = 10
# Number of correct code entries required to dismiss the alarm.
# Requiring more than one round forces the user to stay awake long enough
# to actually read and type multiple independent codes.
DISMISS_ROUNDS_REQUIRED: int = 2
DISMISS_ROUNDS_REQUIRED: int = 4
# Seconds the code is visible before being hidden in a flash challenge.
DISMISS_FLASH_SECONDS: int = 4
# How often the dismiss code refreshes (seconds)

View File

@ -125,15 +125,15 @@ class TestIsAlarmDay:
mock_dt.side_effect = datetime
assert _is_alarm_day() is True
def test_tuesday_is_not_alarm_day(self) -> None:
"""Tuesday (weekday=1) is NOT an alarm day."""
def test_tuesday_is_alarm_day(self) -> None:
"""Tuesday (weekday=1) is an alarm day."""
with patch(
"wake_alarm._alarm.datetime",
) as mock_dt:
mock_now = MagicMock()
mock_now.weekday.return_value = 1 # Tuesday
mock_dt.now.return_value = mock_now
assert _is_alarm_day() is False
assert _is_alarm_day() is True
def test_friday_is_alarm_day(self) -> None:
"""Friday (weekday=4) is an alarm day."""
@ -165,15 +165,25 @@ class TestIsAlarmDay:
mock_dt.now.return_value = mock_now
assert _is_alarm_day() is True
def test_wednesday_is_not_alarm_day(self) -> None:
"""Wednesday (weekday=2) is NOT an alarm day."""
def test_wednesday_is_alarm_day(self) -> None:
"""Wednesday (weekday=2) is an alarm day."""
with patch(
"wake_alarm._alarm.datetime",
) as mock_dt:
mock_now = MagicMock()
mock_now.weekday.return_value = 2
mock_dt.now.return_value = mock_now
assert _is_alarm_day() is False
assert _is_alarm_day() is True
def test_thursday_is_alarm_day(self) -> None:
"""Thursday (weekday=3) is an alarm day."""
with patch(
"wake_alarm._alarm.datetime",
) as mock_dt:
mock_now = MagicMock()
mock_now.weekday.return_value = 3
mock_dt.now.return_value = mock_now
assert _is_alarm_day() is True
class TestSpeakerTestPath:

View File

@ -6,10 +6,13 @@ from unittest.mock import patch
from wake_alarm._challenges import (
_DISMISS_CHARS,
_REVERSE_WORDS,
_Challenge,
_make_challenge,
_make_flash_challenge,
_make_math_challenge,
_make_reverse_challenge,
_make_sequence_challenge,
_make_sort_challenge,
)
from wake_alarm._constants import DISMISS_FLASH_SECONDS
@ -28,12 +31,12 @@ class TestMakeMathChallenge:
patch("wake_alarm._challenges.secrets.choice", return_value="+"),
patch(
"wake_alarm._challenges.secrets.randbelow",
side_effect=[13, 35], # 10+13=23, 10+35=45
side_effect=[100, 200], # 100+100=200, 100+200=300
),
):
ch = _make_math_challenge()
assert ch.display == "23 + 45 = ?"
assert ch.answer == "68"
assert ch.display == "200 + 300 = ?"
assert ch.answer == "500"
def test_answer_is_correct_for_subtraction(self) -> None:
"""Stored answer is numerically correct for subtraction."""
@ -41,12 +44,12 @@ class TestMakeMathChallenge:
patch("wake_alarm._challenges.secrets.choice", return_value="-"),
patch(
"wake_alarm._challenges.secrets.randbelow",
side_effect=[30, 7], # 20+30=50, 10+7=17
side_effect=[100, 50], # 200+100=300, 100+50=150
),
):
ch = _make_math_challenge()
assert ch.display == "50 - 17 = ?"
assert ch.answer == "33"
assert ch.display == "300 - 150 = ?"
assert ch.answer == "150"
def test_answer_is_correct_for_multiplication(self) -> None:
"""Stored answer is numerically correct for multiplication."""
@ -61,6 +64,29 @@ class TestMakeMathChallenge:
assert ch.display == "15 * 7 = ?"
assert ch.answer == "105"
def test_answer_is_correct_for_division(self) -> None:
"""Stored answer is numerically correct for division (always integer)."""
with (
patch("wake_alarm._challenges.secrets.choice", return_value="÷"),
patch(
"wake_alarm._challenges.secrets.randbelow",
side_effect=[2, 5], # b=3+2=5, a=(10+5)*5=75
),
):
ch = _make_math_challenge()
assert ch.display == "75 ÷ 5 = ?"
assert ch.answer == "15"
def test_division_answer_is_always_integer(self) -> None:
"""Division challenges always produce whole-number answers."""
for _ in range(30):
with patch("wake_alarm._challenges.secrets.choice", return_value="÷"):
ch = _make_math_challenge()
# Extract dividend and divisor from display
parts = ch.display.replace(" ÷ ", " ").replace(" = ?", "").split()
dividend, divisor = int(parts[0]), int(parts[1])
assert dividend % divisor == 0
def test_answer_varies_across_calls(self) -> None:
"""Multiple calls produce varied answers (probabilistic)."""
answers = {_make_math_challenge().answer for _ in range(30)}
@ -81,10 +107,10 @@ class TestMakeSortChallenge:
expected = "".join(str(d) for d in sorted(displayed_digits))
assert ch.answer == expected
def test_display_contains_six_digits(self) -> None:
"""Display always contains exactly six digit characters."""
def test_display_contains_eight_digits(self) -> None:
"""Display always contains exactly eight digit characters."""
ch = _make_sort_challenge()
assert len([c for c in ch.display if c.isdigit()]) == 6
assert len([c for c in ch.display if c.isdigit()]) == 8
def test_answer_varies_across_calls(self) -> None:
"""Multiple calls produce varied digit sets."""
@ -115,6 +141,92 @@ class TestMakeFlashChallenge:
assert str(DISMISS_FLASH_SECONDS) in ch.hint
class TestMakeSequenceChallenge:
"""Tests for _make_sequence_challenge."""
def test_kind_is_sequence(self) -> None:
"""Challenge kind is always 'sequence'."""
assert _make_sequence_challenge().kind == "sequence"
def test_arithmetic_answer_is_correct(self) -> None:
"""Arithmetic sequence next term is first + step * 4."""
with patch("wake_alarm._challenges.secrets.randbelow", side_effect=[0, 0, 3]):
# randbelow(2)==0 → arithmetic, start=2+0=2, step=2+3=5
ch = _make_sequence_challenge()
assert ch.display == "2, 7, 12, 17, ?"
assert ch.answer == "22"
def test_geometric_answer_is_correct(self) -> None:
"""Geometric sequence next term is start * ratio^4."""
with patch("wake_alarm._challenges.secrets.randbelow", side_effect=[1, 0, 0]):
# randbelow(2)==1 → geometric, start=2+0=2, ratio=2+0=2
ch = _make_sequence_challenge()
assert ch.display == "2, 4, 8, 16, ?"
assert ch.answer == "32"
def test_answer_is_last_term(self) -> None:
"""The answer is always the 5th term of the displayed sequence."""
for _ in range(30):
ch = _make_sequence_challenge()
# Re-derive from the display
terms_str = ch.display.replace(", ?", "").split(", ")
terms = [int(t) for t in terms_str]
assert len(terms) == 4
# Arithmetic check: constant difference
diffs = [terms[i + 1] - terms[i] for i in range(3)]
if len(set(diffs)) == 1:
expected = terms[-1] + diffs[0]
else:
# Geometric: constant ratio
ratio = terms[1] // terms[0]
expected = terms[-1] * ratio
assert ch.answer == str(expected)
def test_both_series_types_reachable(self) -> None:
"""Both arithmetic and geometric series appear across many calls."""
found_arithmetic = False
found_geometric = False
for _ in range(200):
ch = _make_sequence_challenge()
terms_str = ch.display.replace(", ?", "").split(", ")
terms = [int(t) for t in terms_str]
diffs = [terms[i + 1] - terms[i] for i in range(3)]
if len(set(diffs)) == 1:
found_arithmetic = True
else:
found_geometric = True
if found_arithmetic and found_geometric:
break
assert found_arithmetic
assert found_geometric
class TestMakeReverseChallenge:
"""Tests for _make_reverse_challenge."""
def test_kind_is_reverse(self) -> None:
"""Challenge kind is always 'reverse'."""
assert _make_reverse_challenge().kind == "reverse"
def test_answer_is_word_reversed(self) -> None:
"""Answer equals the displayed word reversed."""
ch = _make_reverse_challenge()
# Extract the word from display (second line)
word = ch.display.split("\n")[1]
assert ch.answer == word[::-1]
def test_word_is_from_pool(self) -> None:
"""Displayed word always comes from _REVERSE_WORDS."""
ch = _make_reverse_challenge()
word = ch.display.split("\n")[1]
assert word in _REVERSE_WORDS
def test_answer_varies_across_calls(self) -> None:
"""Multiple calls produce different words (probabilistic)."""
answers = {_make_reverse_challenge().answer for _ in range(60)}
assert len(answers) > 1
class TestMakeChallenge:
"""Tests for _make_challenge (the random dispatcher)."""
@ -122,12 +234,12 @@ class TestMakeChallenge:
"""Returns a _Challenge instance with all expected fields populated."""
ch = _make_challenge()
assert isinstance(ch, _Challenge)
assert ch.kind in ("math", "flash", "sort")
assert ch.kind in ("math", "flash", "sort", "sequence", "reverse")
assert ch.display
assert ch.answer
assert ch.hint
def test_all_types_reachable(self) -> None:
"""All three challenge types appear across many calls."""
kinds = {_make_challenge().kind for _ in range(200)}
assert kinds == {"math", "flash", "sort"}
"""All five challenge types appear across many calls."""
kinds = {_make_challenge().kind for _ in range(500)}
assert kinds == {"math", "flash", "sort", "sequence", "reverse"}