diff --git a/screen_locker/_log_integrity.py b/screen_locker/_log_integrity.py index a34c6a2..cc4afcc 100644 --- a/screen_locker/_log_integrity.py +++ b/screen_locker/_log_integrity.py @@ -1,78 +1,19 @@ -"""HMAC-based integrity checking for workout log entries.""" +"""HMAC-based integrity checking — re-exports from shared package.""" from __future__ import annotations -import hashlib -import hmac -import json -import logging -import secrets +from python_pkg.shared.log_integrity import ( + HMAC_KEY_FILE, + _generate_hmac_key, + _load_hmac_key, + compute_entry_hmac, + verify_entry_hmac, +) -from python_pkg.screen_locker._constants import HMAC_KEY_FILE - -_logger = logging.getLogger(__name__) - - -def _load_hmac_key() -> bytes | None: - """Load HMAC key from the root-owned key file. - - Returns the key bytes, or None if the file cannot be read. - """ - try: - return HMAC_KEY_FILE.read_bytes().strip() - except OSError: - _logger.warning("Cannot read HMAC key from %s", HMAC_KEY_FILE) - return None - - -def _generate_hmac_key() -> bytes | None: - """Generate a new HMAC key and write it to the key file. - - The key file must be writable (requires root or setup script). - Returns the new key bytes, or None on failure. - """ - key = secrets.token_bytes(32) - try: - HMAC_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) - HMAC_KEY_FILE.write_bytes(key) - except OSError: - _logger.warning("Cannot write HMAC key to %s", HMAC_KEY_FILE) - return None - return key - - -def compute_entry_hmac(entry_data: dict[str, object]) -> str | None: - """Compute HMAC-SHA256 for a workout log entry. - - Args: - entry_data: The log entry dict (without the 'hmac' field). - - Returns: - Hex-encoded HMAC string, or None if the key is unavailable. - """ - key = _load_hmac_key() - if key is None: - return None - payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":")) - return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() - - -def verify_entry_hmac(entry: dict[str, object]) -> bool: - """Verify HMAC signature of a workout log entry. - - Args: - entry: The full log entry dict including the 'hmac' field. - - Returns: - True if the HMAC is valid, False if invalid or key unavailable. - """ - stored_hmac = entry.get("hmac") - if not isinstance(stored_hmac, str): - return False - key = _load_hmac_key() - if key is None: - return False - entry_without_hmac = {k: v for k, v in entry.items() if k != "hmac"} - payload = json.dumps(entry_without_hmac, sort_keys=True, separators=(",", ":")) - expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() - return hmac.compare_digest(stored_hmac, expected) +__all__ = [ + "HMAC_KEY_FILE", + "_generate_hmac_key", + "_load_hmac_key", + "compute_entry_hmac", + "verify_entry_hmac", +] diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index bb90a56..65bd91e 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -2,7 +2,8 @@ from __future__ import annotations -from datetime import datetime, timezone +import calendar +from datetime import datetime, timedelta, timezone import json import logging import subprocess @@ -12,6 +13,11 @@ from python_pkg.screen_locker._constants import ( SHUTDOWN_CONFIG_FILE, SICK_DAY_STATE_FILE, ) +from python_pkg.wake_alarm._constants import ( + ALARM_DAYS, + RTCWAKE_BIN, + WAKE_AFTER_HOURS, +) _logger = logging.getLogger(__name__) @@ -260,3 +266,73 @@ class ShutdownMixin: result.stdout.strip(), ) return True + + # ------------------------------------------------------------------ + # rtcwake integration for weekend wake alarm + # ------------------------------------------------------------------ + + @staticmethod + def _is_tomorrow_alarm_day() -> bool: + """Check if tomorrow is an alarm day.""" + tomorrow = datetime.now(tz=timezone.utc) + timedelta(days=1) + return tomorrow.weekday() in ALARM_DAYS + + @staticmethod + def _compute_wake_timestamp() -> int: + """Compute the UTC epoch timestamp for the next wake alarm. + + Returns: + Epoch seconds WAKE_AFTER_HOURS from now. + """ + wake_time = datetime.now(tz=timezone.utc) + timedelta( + hours=WAKE_AFTER_HOURS, + ) + return calendar.timegm(wake_time.utctimetuple()) + + @staticmethod + def _schedule_rtcwake() -> bool: + """Set rtcwake to power on the PC after WAKE_AFTER_HOURS. + + Uses ``rtcwake -m no`` so the system can shut down normally while + the RTC alarm remains set. + + Returns: + True if rtcwake was set successfully, False otherwise. + """ + wake_epoch = ShutdownMixin._compute_wake_timestamp() + cmd = [ + "/usr/bin/sudo", + RTCWAKE_BIN, + "-m", + "no", + "-t", + str(wake_epoch), + ] + try: + subprocess.run( + cmd, + check=True, + capture_output=True, + text=True, + ) + except subprocess.SubprocessError as exc: + _logger.warning("Failed to set rtcwake: %s", exc) + return False + _logger.info( + "rtcwake set: PC will wake at epoch %d", + wake_epoch, + ) + return True + + def schedule_wake_if_needed(self) -> bool: + """Schedule rtcwake if tomorrow is an alarm day. + + Call this at shutdown time. + + Returns: + True if wake was scheduled, False if not needed or failed. + """ + if not self._is_tomorrow_alarm_day(): + _logger.info("Tomorrow is not an alarm day — skipping rtcwake") + return False + return self._schedule_rtcwake() diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 462cc2c..ee605ca 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -31,6 +31,7 @@ from python_pkg.screen_locker._log_integrity import ( from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin from python_pkg.screen_locker._shutdown import ShutdownMixin from python_pkg.screen_locker._ui_flows import UIFlowsMixin +from python_pkg.wake_alarm._state import has_workout_skip_today if TYPE_CHECKING: from collections.abc import Callable @@ -92,6 +93,9 @@ class ScreenLocker( elif self.has_logged_today(): _logger.info("Workout already logged today. Skipping screen lock.") sys.exit(0) + elif has_workout_skip_today(): + _logger.info("Wake alarm earned workout skip. Skipping screen lock.") + sys.exit(0) self.root = tk.Tk() title_suffix = ( " [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "") diff --git a/screen_locker/tests/test_log_integrity.py b/screen_locker/tests/test_log_integrity.py index e3fb191..2200c80 100644 --- a/screen_locker/tests/test_log_integrity.py +++ b/screen_locker/tests/test_log_integrity.py @@ -15,6 +15,8 @@ from python_pkg.screen_locker._log_integrity import ( verify_entry_hmac, ) +_HMAC_KEY_FILE_PATH = "python_pkg.shared.log_integrity.HMAC_KEY_FILE" + if TYPE_CHECKING: from pathlib import Path @@ -27,7 +29,7 @@ class TestLoadHmacKey: key_file = tmp_path / "hmac.key" key_file.write_bytes(b"secret_key_bytes") with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, key_file, ): result = _load_hmac_key() @@ -37,7 +39,7 @@ class TestLoadHmacKey: """Test returns None when key file doesn't exist.""" key_file = tmp_path / "nonexistent.key" with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, key_file, ): result = _load_hmac_key() @@ -51,7 +53,7 @@ class TestGenerateHmacKey: """Test key generation creates file with 32-byte key.""" key_file = tmp_path / "subdir" / "hmac.key" with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, key_file, ): result = _generate_hmac_key() @@ -62,7 +64,7 @@ class TestGenerateHmacKey: def test_returns_none_on_write_failure(self) -> None: """Test returns None when file cannot be written.""" with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, ) as mock_path: mock_path.parent.mkdir.side_effect = OSError("permission denied") result = _generate_hmac_key() @@ -79,7 +81,7 @@ class TestComputeEntryHmac: key_file.write_bytes(key) entry = {"timestamp": "2025-01-01T00:00:00", "workout_data": {"type": "test"}} with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, key_file, ): result = compute_entry_hmac(entry) @@ -93,7 +95,7 @@ class TestComputeEntryHmac: """Test returns None when key file is missing.""" key_file = tmp_path / "nonexistent.key" with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, key_file, ): result = compute_entry_hmac({"data": "test"}) @@ -114,7 +116,7 @@ class TestVerifyEntryHmac: entry = {**entry_data, "hmac": correct_hmac} with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, key_file, ): assert verify_entry_hmac(entry) is True @@ -126,7 +128,7 @@ class TestVerifyEntryHmac: entry = {"timestamp": "2025-01-01", "hmac": "wrong_hmac_value"} with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, key_file, ): assert verify_entry_hmac(entry) is False @@ -146,7 +148,7 @@ class TestVerifyEntryHmac: key_file = tmp_path / "nonexistent.key" entry = {"timestamp": "2025-01-01", "hmac": "some_hmac"} with patch( - "python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE", + _HMAC_KEY_FILE_PATH, key_file, ): assert verify_entry_hmac(entry) is False diff --git a/screen_locker/tests/test_wake_shutdown.py b/screen_locker/tests/test_wake_shutdown.py new file mode 100644 index 0000000..4881f86 --- /dev/null +++ b/screen_locker/tests/test_wake_shutdown.py @@ -0,0 +1,188 @@ +"""Tests for rtcwake integration in ShutdownMixin.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestIsTomorrowAlarmDay: + """Tests for _is_tomorrow_alarm_day.""" + + def test_sunday_evening_means_monday_alarm( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Sunday evening → Monday is alarm day (weekday=0).""" + locker = create_locker(mock_tk, tmp_path) + from datetime import datetime, timezone + + # Sunday 2026-04-12 → tomorrow Monday + with patch( + "python_pkg.screen_locker._shutdown.datetime", + ) as mock_dt: + mock_dt.now.return_value = datetime(2026, 4, 12, 23, 0, tzinfo=timezone.utc) + mock_dt.side_effect = datetime + from datetime import timedelta + + # Ensure timedelta works + with patch( + "python_pkg.screen_locker._shutdown.timedelta", + timedelta, + ): + assert locker._is_tomorrow_alarm_day() is True + + def test_monday_evening_is_not_alarm_next( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Monday evening → Tuesday is NOT an alarm day.""" + locker = create_locker(mock_tk, tmp_path) + from datetime import datetime, timedelta, timezone + + # Monday 2026-04-13 → tomorrow Tuesday (weekday=1) + with ( + patch( + "python_pkg.screen_locker._shutdown.datetime", + ) as mock_dt, + patch( + "python_pkg.screen_locker._shutdown.timedelta", + timedelta, + ), + ): + mock_dt.now.return_value = datetime(2026, 4, 13, 23, 0, tzinfo=timezone.utc) + mock_dt.side_effect = datetime + assert locker._is_tomorrow_alarm_day() is False + + def test_thursday_evening_friday_is_alarm( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Thursday evening → Friday is alarm day (weekday=4).""" + locker = create_locker(mock_tk, tmp_path) + from datetime import datetime, timedelta, timezone + + # Thursday 2026-04-16 → tomorrow Friday (weekday=4) + with ( + patch( + "python_pkg.screen_locker._shutdown.datetime", + ) as mock_dt, + patch( + "python_pkg.screen_locker._shutdown.timedelta", + timedelta, + ), + ): + mock_dt.now.return_value = datetime(2026, 4, 16, 23, 0, tzinfo=timezone.utc) + mock_dt.side_effect = datetime + assert locker._is_tomorrow_alarm_day() is True + + +class TestScheduleRtcwake: + """Tests for _schedule_rtcwake.""" + + def test_success( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Successful rtcwake call returns True.""" + locker = create_locker(mock_tk, tmp_path) + with patch( + "python_pkg.screen_locker._shutdown.subprocess.run", + ) as mock_run: + mock_run.return_value = MagicMock(returncode=0) + assert locker._schedule_rtcwake() is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "rtcwake" in cmd[1] + + def test_failure_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Failed rtcwake call returns False.""" + locker = create_locker(mock_tk, tmp_path) + import subprocess + + with patch( + "python_pkg.screen_locker._shutdown.subprocess.run", + side_effect=subprocess.SubprocessError("rtcwake failed"), + ): + assert locker._schedule_rtcwake() is False + + +class TestScheduleWakeIfNeeded: + """Tests for schedule_wake_if_needed.""" + + def test_skips_when_not_alarm_day( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when tomorrow is not an alarm day.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_is_tomorrow_alarm_day", return_value=False): + assert locker.schedule_wake_if_needed() is False + + def test_schedules_when_alarm_day( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns True when tomorrow is an alarm day and rtcwake succeeds.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_tomorrow_alarm_day", return_value=True), + patch.object(locker, "_schedule_rtcwake", return_value=True), + ): + assert locker.schedule_wake_if_needed() is True + + def test_returns_false_when_rtcwake_fails( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when rtcwake call fails.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_tomorrow_alarm_day", return_value=True), + patch.object(locker, "_schedule_rtcwake", return_value=False), + ): + assert locker.schedule_wake_if_needed() is False + + +class TestComputeWakeTimestamp: + """Tests for _compute_wake_timestamp.""" + + def test_returns_future_epoch( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Wake timestamp is roughly 8 hours from now.""" + locker = create_locker(mock_tk, tmp_path) + import time + + now = int(time.time()) + wake = locker._compute_wake_timestamp() + # Should be ~8 hours ahead (within 60 second tolerance) + expected = now + 8 * 3600 + assert abs(wake - expected) < 60 diff --git a/screen_locker/tests/test_wake_skip.py b/screen_locker/tests/test_wake_skip.py new file mode 100644 index 0000000..a7becca --- /dev/null +++ b/screen_locker/tests/test_wake_skip.py @@ -0,0 +1,83 @@ +"""Tests for wake alarm skip integration in screen_lock.py.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestWakeSkipIntegration: + """Tests for workout skip via wake alarm in screen locker init.""" + + def test_exits_when_wake_skip_active( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Screen locker exits if wake alarm granted workout skip today.""" + with patch( + "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + return_value=True, + ): + create_locker(mock_tk, tmp_path, has_logged=False) + + mock_sys_exit.assert_called_once_with(0) + + def test_does_not_exit_when_no_wake_skip( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Screen locker proceeds normally if no wake skip active.""" + with patch( + "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + return_value=False, + ): + locker = create_locker(mock_tk, tmp_path, has_logged=False) + + mock_sys_exit.assert_not_called() + assert locker is not None + + def test_logged_today_takes_precedence( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """has_logged_today exits before wake skip is even checked.""" + with patch( + "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + return_value=True, + ): + create_locker(mock_tk, tmp_path, has_logged=True) + + # Exits because has_logged_today, not because of wake skip + mock_sys_exit.assert_called_once_with(0) + + def test_verify_only_mode_ignores_wake_skip( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """verify_only mode checks sick day log, not wake skip.""" + with patch( + "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + return_value=True, + ): + create_locker( + mock_tk, + tmp_path, + verify_only=True, + is_sick_day_log=True, + ) + + # In verify_only mode, exits don't happen from wake skip path + mock_sys_exit.assert_not_called()