mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Top-right always-on-top strip while the signed morning session is in_progress: title, "Next: <step>", "Locks return at HH:MM". wake-alarm-strip.path starts it on every file rewrite; it re-reads the file each second, asks for a refresh every 30 s, exits 0 when the session ends. No display/output: warning, exit 1. Bump gatelock pin to 0.12.0 (corner_strip). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
248 lines
8.3 KiB
Python
248 lines
8.3 KiB
Python
"""A corner strip while the morning session is started but not finished.
|
|
|
|
wake-alarm's signed ``morning_session.json`` makes the lockers stand down
|
|
while the morning is live. The screen simply clears, though, and kuhy still
|
|
owes the rest of the routine: the locks return at ``exempt_until`` out of
|
|
nowhere. This strip is the reminder: a small always-on-top box in the
|
|
top-right corner of the primary output (``gatelock.corner_strip``), saying the
|
|
session is not finished, what the phone's next step is, and when locks return.
|
|
|
|
python -m wake_alarm._session_strip
|
|
|
|
``wake-alarm-strip.path`` starts it whenever the file is rewritten (every two
|
|
minutes through the morning); starting a running service is a no-op. It shows
|
|
only while the entry is *signed, dated today, in_progress, and now <
|
|
exempt_until*, re-reading the file every second, and exits 0 the moment that
|
|
stops being true. While visible it also asks systemd every 30 s to run
|
|
``wake-alarm-session.service`` so ``next_step`` follows the phone instead of
|
|
the two-minute timer.
|
|
|
|
Fail modes, out loud: nothing to show is exit 0 (normal, logged at info); no
|
|
display or no live output is a warning and exit 1, so the unit shows failed;
|
|
a missing, unreadable or tampered file shows nothing (the lockers do not
|
|
honour it either) and warns once per distinct problem, not once a second.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
import json
|
|
import logging
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import LockConfig, enumerate_outputs
|
|
from gatelock.corner_strip import StripContent, build_corner_strip, corner_rect
|
|
from gatelock.log_integrity import verify_entry_hmac
|
|
|
|
from wake_alarm._constants import MORNING_SESSION_FILE
|
|
from wake_alarm._logging_setup import configure_logging
|
|
|
|
if TYPE_CHECKING:
|
|
from subprocess import Popen
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
TITLE = "Morning session not finished"
|
|
REFRESH_EVERY_SECONDS = 30.0
|
|
REFRESH_COMMAND = [
|
|
"systemctl",
|
|
"--user",
|
|
"start",
|
|
"--no-block",
|
|
"wake-alarm-session.service",
|
|
]
|
|
|
|
|
|
class _Once:
|
|
"""Warns about a problem once, until a clean read clears it.
|
|
|
|
The strip reads the file every second; a file that stays broken for an
|
|
hour must be one journal line, not 3600.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.last: str | None = None
|
|
|
|
def warn(self, message: str) -> None:
|
|
"""Log ``message`` at warning level unless it was just logged."""
|
|
if message != self.last:
|
|
_logger.warning(message)
|
|
self.last = message
|
|
|
|
def clear(self) -> None:
|
|
"""A clean read happened: the next problem is news again."""
|
|
self.last = None
|
|
|
|
|
|
_once = _Once()
|
|
|
|
|
|
def _local_now() -> datetime:
|
|
"""The current time as an aware local datetime (a test seam)."""
|
|
return datetime.now().astimezone()
|
|
|
|
|
|
def _monotonic() -> float:
|
|
"""Seconds on the monotonic clock (a test seam)."""
|
|
return time.monotonic()
|
|
|
|
|
|
def _popen(cmd: list[str]) -> Popen[bytes]:
|
|
"""Start ``cmd`` without waiting for it (a test seam)."""
|
|
return subprocess.Popen(cmd)
|
|
|
|
|
|
def _make_root() -> tk.Tk:
|
|
"""A withdrawn Tk root; only the strip's Toplevel is ever visible."""
|
|
root = tk.Tk()
|
|
root.withdraw()
|
|
return root
|
|
|
|
|
|
def read_entry() -> dict[str, object] | None:
|
|
"""The file's parsed JSON object, or None. Verification is the caller's."""
|
|
try:
|
|
text = MORNING_SESSION_FILE.read_text()
|
|
except FileNotFoundError:
|
|
return None
|
|
except OSError as exc:
|
|
_once.warn(f"Cannot read {MORNING_SESSION_FILE}: {exc}")
|
|
return None
|
|
try:
|
|
entry = json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
_once.warn(f"Cannot parse {MORNING_SESSION_FILE}: {exc}")
|
|
return None
|
|
if not isinstance(entry, dict):
|
|
_once.warn(f"{MORNING_SESSION_FILE} is not a JSON object")
|
|
return None
|
|
_once.clear()
|
|
return entry
|
|
|
|
|
|
def _why_not_today(entry: dict[str, object], now: datetime) -> str | None:
|
|
"""Why ``entry`` is not a verified, live, in-progress morning; None if it is."""
|
|
if not verify_entry_hmac(entry):
|
|
return "morning session entry is unsigned or tampered"
|
|
if entry.get("date") != now.date().isoformat():
|
|
return f"morning session is dated {entry.get('date')}, not today"
|
|
if entry.get("outcome") != "in_progress":
|
|
return f"morning session outcome is {entry.get('outcome')}"
|
|
return None
|
|
|
|
|
|
def _exempt_until(entry: dict[str, object]) -> datetime | str:
|
|
"""The entry's aware ``exempt_until``, or the sentence saying why not."""
|
|
until = entry.get("exempt_until")
|
|
if not isinstance(until, str):
|
|
return "morning session has no exempt_until"
|
|
try:
|
|
parsed = datetime.fromisoformat(until)
|
|
except ValueError:
|
|
return f"morning session exempt_until is unreadable: {until!r}"
|
|
if parsed.tzinfo is None:
|
|
# Guessing its zone would be inventing a carrot the signer never gave.
|
|
return "morning session exempt_until has no timezone"
|
|
return parsed
|
|
|
|
|
|
def _verdict(entry: dict[str, object] | None, now: datetime) -> StripContent | str:
|
|
"""The strip's content, or the sentence saying why there is none."""
|
|
if entry is None:
|
|
return "no morning session file"
|
|
reason = _why_not_today(entry, now)
|
|
if reason is not None:
|
|
return reason
|
|
exempt_until = _exempt_until(entry)
|
|
if isinstance(exempt_until, str):
|
|
return exempt_until
|
|
if now >= exempt_until:
|
|
return f"the exemption ended at {exempt_until.isoformat()}"
|
|
lines: list[str] = []
|
|
step = entry.get("next_step")
|
|
if isinstance(step, str) and step:
|
|
lines.append(f"Next: {step}")
|
|
lines.append(f"Locks return at {exempt_until.astimezone(now.tzinfo):%H:%M}")
|
|
return StripContent(TITLE, tuple(lines))
|
|
|
|
|
|
def strip_content(
|
|
entry: dict[str, object] | None, now: datetime
|
|
) -> StripContent | None:
|
|
"""What the strip says for ``entry`` at local ``now``; None shows nothing.
|
|
|
|
Something is shown only for a verified entry dated today, still
|
|
``in_progress``, whose ``exempt_until`` has not passed.
|
|
"""
|
|
verdict = _verdict(entry, now)
|
|
return verdict if isinstance(verdict, StripContent) else None
|
|
|
|
|
|
class Refresher:
|
|
"""Starts wake-alarm-session.service every 30 s, never two at once."""
|
|
|
|
def __init__(self, *, start: float) -> None:
|
|
self._due = start + REFRESH_EVERY_SECONDS
|
|
self._proc: Popen[bytes] | None = None
|
|
|
|
def tick(self, now: float) -> None:
|
|
"""Reap a finished refresh, and start the next one when it is due."""
|
|
if self._proc is not None:
|
|
code = self._proc.poll()
|
|
if code is None:
|
|
return
|
|
if code != 0:
|
|
_logger.warning("the session refresh start exited %d", code)
|
|
self._proc = None
|
|
if now < self._due:
|
|
return
|
|
self._due = now + REFRESH_EVERY_SECONDS
|
|
try:
|
|
self._proc = _popen(REFRESH_COMMAND)
|
|
except OSError as exc:
|
|
_logger.warning("could not start the session refresh: %s", exc)
|
|
|
|
|
|
def main() -> int:
|
|
"""Show the strip while the morning is in progress; 0 when it is over."""
|
|
configure_logging(component="session-strip")
|
|
verdict = _verdict(read_entry(), _local_now())
|
|
if not isinstance(verdict, StripContent):
|
|
_logger.info("Nothing to show: %s", verdict)
|
|
return 0
|
|
try:
|
|
root = _make_root()
|
|
except tk.TclError as exc:
|
|
_logger.warning("Cannot open the display for the strip: %s", exc)
|
|
return 1
|
|
rect = corner_rect(enumerate_outputs(root))
|
|
if rect is None:
|
|
_logger.warning("Cannot place the strip: no live output on this display")
|
|
root.destroy()
|
|
return 1
|
|
strip = build_corner_strip(root, LockConfig(), verdict, rect=rect)
|
|
refresher = Refresher(start=_monotonic())
|
|
ending: list[str] = []
|
|
|
|
def supply() -> StripContent | None:
|
|
current = _verdict(read_entry(), _local_now())
|
|
if not isinstance(current, StripContent):
|
|
ending.append(current)
|
|
return None
|
|
refresher.tick(_monotonic())
|
|
return current
|
|
|
|
strip.schedule(supply, on_close=root.quit)
|
|
root.mainloop()
|
|
root.destroy()
|
|
_logger.info("Strip closed: %s", ending[0] if ending else "window gone")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|