wake-alarm/wake_alarm/_session_strip.py
Krzysztof kuhy Rudnicki f182453567 Add morning-session corner strip
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
2026-11-07 08:19:42 +01:00

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())