mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
5.9 KiB
Python
185 lines
5.9 KiB
Python
"""Where the daily audit's evidence comes from -- read-only, all of it.
|
|
|
|
* PC events: every ``events.jsonl*`` line (the live file rotates at 5 MB)
|
|
that carries a top-level ``event`` key -- the named events only, or the
|
|
timeline would be hundreds of lines of routine sync chatter.
|
|
* Boots: ``journalctl --list-boots -o json``.
|
|
* Resumes: kernel ``Waking up from system sleep state`` lines, read with
|
|
``_TRANSPORT=kernel`` and a time range -- ``-k`` would cover only the
|
|
current boot.
|
|
|
|
Every ``journalctl`` call goes through ``_run``, the seam the suite replaces.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime, timedelta
|
|
import json
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
|
|
from wake_alarm._constants import LOG_JSONL_FILE
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import date
|
|
|
|
_RESUME_PATTERN = "Waking up from system sleep state"
|
|
_USEC = 1_000_000
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PcEvent:
|
|
"""One named line of ``events.jsonl``."""
|
|
|
|
at: datetime
|
|
event: str
|
|
message: str
|
|
fields: dict[str, object] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Up:
|
|
"""The PC became usable: a boot, or a resume from sleep."""
|
|
|
|
at: datetime
|
|
kind: str
|
|
|
|
|
|
# The phone judges up to this many past mornings on a boot/watchdog check.
|
|
STICK_CATCH_UP_DAYS = 5
|
|
|
|
|
|
def _midnight(day: date) -> datetime:
|
|
"""Local midnight starting ``day``."""
|
|
return datetime.combine(day, datetime.min.time()).astimezone()
|
|
|
|
|
|
def audit_window(day: date) -> tuple[datetime, datetime]:
|
|
"""PC evidence: from the start of the day before to the end of ``day``.
|
|
|
|
The RTC is re-armed every 15 min, so the first ``rtc_armed`` for ``day``
|
|
is logged any time after the previous morning's wake instant.
|
|
"""
|
|
start = _midnight(day)
|
|
return start - timedelta(days=1), start + timedelta(days=1)
|
|
|
|
|
|
def phone_window(day: date) -> tuple[datetime, datetime]:
|
|
"""Phone evidence: also the days after, when a late check judged ``day``."""
|
|
start, end = audit_window(day)
|
|
return start, end + timedelta(days=STICK_CATCH_UP_DAYS)
|
|
|
|
|
|
def timeline_window(day: date) -> tuple[datetime, datetime]:
|
|
"""What the timeline shows: ``day`` and the evening before."""
|
|
start = _midnight(day)
|
|
return start - timedelta(hours=6), start + timedelta(days=1)
|
|
|
|
|
|
def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
|
"""Run ``cmd``; the seam tests replace."""
|
|
return subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
|
|
|
|
|
|
def _journal(cmd: list[str]) -> str:
|
|
"""``cmd``'s stdout, or ``""`` when journalctl is missing or failed."""
|
|
try:
|
|
result = _run(cmd)
|
|
except OSError, subprocess.SubprocessError:
|
|
return ""
|
|
return result.stdout if result.returncode == 0 else ""
|
|
|
|
|
|
def _pc_line(line: str) -> tuple[datetime, PcEvent | None] | None:
|
|
"""One JSONL line: its instant, and the named event it carries (if any)."""
|
|
try:
|
|
raw = json.loads(line)
|
|
at = datetime.fromisoformat(str(raw.get("ts") or "")).astimezone()
|
|
except ValueError, AttributeError:
|
|
return None
|
|
if not isinstance(raw.get("event"), str):
|
|
return at, None
|
|
fields = raw.get("fields")
|
|
return at, PcEvent(
|
|
at=at,
|
|
event=raw["event"],
|
|
message=str(raw.get("msg") or ""),
|
|
fields=dict(fields) if isinstance(fields, dict) else {},
|
|
)
|
|
|
|
|
|
def read_pc_log(day: date) -> tuple[list[PcEvent], list[datetime]]:
|
|
"""Inside :func:`audit_window`: named events, and when anything was logged.
|
|
|
|
The second list proves the PC was running even when nothing named
|
|
happened (the trigger and the sync reader log routinely).
|
|
"""
|
|
start, end = audit_window(day)
|
|
named: list[PcEvent] = []
|
|
seen: list[datetime] = []
|
|
for path in sorted(LOG_JSONL_FILE.parent.glob(LOG_JSONL_FILE.name + "*")):
|
|
try:
|
|
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except OSError:
|
|
continue
|
|
for line in lines:
|
|
parsed = _pc_line(line)
|
|
if parsed is None or not start <= parsed[0] < end:
|
|
continue
|
|
seen.append(parsed[0])
|
|
if parsed[1] is not None:
|
|
named.append(parsed[1])
|
|
return sorted(named, key=lambda e: e.at), sorted(seen)
|
|
|
|
|
|
def boots(day: date) -> list[Up]:
|
|
"""Boots that started inside :func:`audit_window`."""
|
|
start, end = audit_window(day)
|
|
try:
|
|
listed = json.loads(
|
|
_journal(["journalctl", "--list-boots", "-o", "json", "--no-pager"]) or "[]"
|
|
)
|
|
except ValueError:
|
|
return []
|
|
ups: list[Up] = []
|
|
for boot in listed if isinstance(listed, list) else []:
|
|
first = boot.get("first_entry") if isinstance(boot, dict) else None
|
|
if isinstance(first, int):
|
|
at = datetime.fromtimestamp(first / _USEC, tz=UTC).astimezone()
|
|
if start <= at < end:
|
|
ups.append(Up(at=at, kind="boot"))
|
|
return ups
|
|
|
|
|
|
def resumes(day: date) -> list[Up]:
|
|
"""Kernel wake-from-sleep lines inside :func:`audit_window`."""
|
|
start, end = audit_window(day)
|
|
out = _journal(
|
|
[
|
|
"journalctl",
|
|
"_TRANSPORT=kernel",
|
|
"--since",
|
|
start.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"--until",
|
|
end.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"-g",
|
|
_RESUME_PATTERN,
|
|
"-o",
|
|
"json",
|
|
"--no-pager",
|
|
]
|
|
)
|
|
ups: list[Up] = []
|
|
for line in out.splitlines():
|
|
try:
|
|
entry = json.loads(line)
|
|
stamp = int(entry["__REALTIME_TIMESTAMP"])
|
|
except ValueError, KeyError, TypeError:
|
|
continue
|
|
message = str(entry.get("MESSAGE") or "")
|
|
state = message.rsplit(" ", 1)[-1] if message else "?"
|
|
at = datetime.fromtimestamp(stamp / _USEC, tz=UTC).astimezone()
|
|
ups.append(Up(at=at, kind=f"resume {state}"))
|
|
return ups
|