screen-locker/screen_locker/_web_payload.py
Krzysztof kuhy Rudnicki 1d5965b9a7
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Close the four gaps that let a decided lock never reach the screen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:24:45 +02:00

202 lines
7.5 KiB
Python

"""JSON payloads for the local status web UI.
One read-only view, shared by the HTTP server and anything else that wants the
same answer. Deliberately built on :func:`~screen_locker._status_data.gather_status`
rather than on a second walk of the log: ``_mcp`` already proves that snapshot
round-trips through ``json.dumps`` unchanged, and a second projection is exactly
how the status view came to explain a decision the locker never made.
Nothing here reads a secret. The sync token and the HMAC key never appear in a
payload, and no endpoint takes input that reaches the filesystem.
"""
from __future__ import annotations
from dataclasses import asdict
from datetime import UTC, datetime
import logging
from pathlib import Path
from typing import Any
from screen_locker._armed_state import (
collect_states,
locker_unit_active,
systemctl_available,
)
from screen_locker._decision_log import DECISION_LOG_FILE, read_decisions
from screen_locker._log_io import load_workout_log
from screen_locker._queue_state import read_queue_wait
from screen_locker._status_data import gather_status
from screen_locker._status_projection import (
compliance_state_word,
format_summary_line,
)
from screen_locker._weekly_check import count_day_credits
_logger = logging.getLogger(__name__)
# The workout log lives beside the package, same default as
# ``_status_data._DEFAULT_LOG_FILE``; declared here to avoid importing a
# private name across modules, exactly as ``_mcp`` does.
LOG_FILE = Path(__file__).resolve().parent / "log.json"
# The marker that disables enforcement machine-wide. Kept in sync with
# scripts/disarm_guard.sh, which is the thing that writes it.
DISARM_MARKER = Path.home() / ".local" / "share" / "screen_locker" / "DISARMED"
def workout_credits_today(*, now: datetime | None = None) -> int:
"""Return how many workout credits today has earned so far.
Uses :func:`~screen_locker._weekly_check.count_day_credits`, the same rule
the locker enforces with, so "did I work out today" cannot drift from "does
the locker think I worked out today".
Args:
now: Override for the current local datetime (for testing).
Returns:
The number of distinct credits earned today, zero if none.
"""
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
today = moment.date().isoformat()
entries = load_workout_log(LOG_FILE).get(today, [])
return count_day_credits(today, [e for e in entries if isinstance(e, dict)])
def build_status_payload(*, now: datetime | None = None) -> dict[str, Any]:
"""Build the full status payload.
Args:
now: Override for the current local datetime (for testing).
Returns:
The status snapshot plus the derived one-liners and the gaming-budget
input the enforcer reads.
"""
snapshot = gather_status()
credits_today = workout_credits_today(now=now)
return {
"snapshot": asdict(snapshot),
# Whether a locker run has decided to lock and is waiting for a
# higher-ranked holder to release the screen. Without this the page
# says "Lock would fire" both when nothing is running and when a run
# is blocked behind wake_alarm -- two very different situations that
# looked identical on 2026-08-30.
"queue_wait": read_queue_wait(),
"locker_running": locker_unit_active(),
"summary_line": format_summary_line(snapshot),
"compliance_state": compliance_state_word(snapshot),
# The gaming budget is decided by steam-backlog-enforcer, which owns the
# hour values. This reports only the fact the decision turns on, so the
# two repos cannot disagree about what a "workout day" is.
"gaming": {
"workout_today": credits_today > 0,
"credits_today": credits_today,
"reason": (
f"{credits_today} counted workout(s) logged today"
if credits_today
else "no counted workout logged today"
),
},
}
def _log_age_seconds(*, now: datetime | None = None) -> float | None:
"""Return how long ago the workout log was last written.
Args:
now: Override for the current local datetime (for testing).
Returns:
Age in seconds, or ``None`` when the log does not exist.
"""
try:
mtime = LOG_FILE.stat().st_mtime
except OSError:
_logger.warning(
"Cannot stat the workout log at %s — reporting its age as unknown "
"rather than as fresh",
LOG_FILE,
)
return None
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
return moment.timestamp() - mtime
def _last_decision_age_seconds(*, now: datetime | None = None) -> float | None:
"""Return how long ago a decision was last recorded.
A locker that stopped running goes quiet rather than failing, so the age of
the newest decision is the most direct evidence that it is still alive.
Args:
now: Override for the current local datetime (for testing).
Returns:
Age in seconds, or ``None`` when nothing has been recorded.
"""
decisions = read_decisions()
if not decisions:
return None
newest = decisions[-1]
# `timestamp` is when a collapsed row's streak *began*, which for the
# every-15-minutes --sync-only runs would report an hours-old trail as
# stale while it is in fact current. `last_timestamp` is the most recent
# sighting, so it is what freshness must be measured from.
stamp = newest.get("last_timestamp", newest.get("timestamp"))
if not isinstance(stamp, str):
return None
try:
recorded = datetime.fromisoformat(stamp)
except ValueError:
_logger.warning(
"Newest decision has an unparsable timestamp %r — reporting its "
"age as unknown rather than as recent",
stamp,
)
return None
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
return moment.timestamp() - recorded.timestamp()
def build_health_payload(*, now: datetime | None = None) -> dict[str, Any]:
"""Build the health payload: is the locker actually able to fire.
Every field here exists because its absence once went unnoticed. A timer
silently deleted to break an ordering cycle, a disarm marker left in place,
a log that stopped being written -- none of those raise an error anywhere,
so each is reported as a value rather than left to be inferred from silence.
Args:
now: Override for the current local datetime (for testing).
Returns:
Timer arming state, the disarm marker, and freshness of log and trail.
"""
checked = systemctl_available()
timers = [
{
"name": state.name,
"enabled": state.enabled,
"scheduled": state.scheduled,
"armed": state.armed,
"describe": state.describe(),
}
for state in (collect_states() if checked else ())
]
disarmed = DISARM_MARKER.exists()
return {
# "could not check" is reported as its own state: it must never be
# rendered as a green tick.
"timers_checked": checked,
"timers": timers,
"armed": checked and not disarmed and all(t["armed"] for t in timers),
"disarmed": disarmed,
"disarm_marker": str(DISARM_MARKER),
"log_file": str(LOG_FILE),
"log_age_seconds": _log_age_seconds(now=now),
"decision_log_file": str(DECISION_LOG_FILE),
"last_decision_age_seconds": _last_decision_age_seconds(now=now),
}