mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
7.2 KiB
Python
Executable File
216 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Read a stopped guest's CMOS RTC alarm from the host, over QMP.
|
|
|
|
This is the "did the wake instant survive the guest going down?" probe for
|
|
`vm wake`. QEMU keeps a powered-off guest's CMOS alive while the machine sits
|
|
in the 'shutdown' runstate (see `qmp.py pause-on-shutdown`), so the alarm the
|
|
guest armed with `rtcwake -m no` can be read straight out of the emulated
|
|
MC146818 -- not from anything the guest reported about itself.
|
|
|
|
The alarm registers hold only h:m:s (QEMU's FADT has no day/month alarm:
|
|
"alarms up to one day"), so the wake instant is the NEXT time the RTC matches
|
|
them -- the same comparison the hardware makes.
|
|
|
|
Usage:
|
|
rtc_alarm.py read <qmp.sock.ctl> # JSON report; guest must be stopped
|
|
rtc_alarm.py now <qmp.sock.ctl> # current RTC as a UTC epoch
|
|
rtc_alarm.py decode <rtc_epoch> <sec> <min> <hour> <reg_b> [<pm1_en>]
|
|
# offline decode (unit tests); hex ok
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any, TextIO
|
|
|
|
from qmp import command, open_session
|
|
|
|
REG_B_SET_24H = 0x02
|
|
REG_B_BINARY = 0x04
|
|
REG_B_AIE = 0x20
|
|
PM1_EN_RTC_EN = 0x400
|
|
DONT_CARE = 0xC0
|
|
# ICH9 ACPI PM I/O base as programmed by both SeaBIOS and OVMF on q35.
|
|
PM_BASE = int(os.environ.get("VMBOX_PM_BASE", "0x600"), 0)
|
|
CMOS_ALARM_REGS = {"sec": 0x01, "min": 0x03, "hour": 0x05, "reg_b": 0x0B}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AlarmReport:
|
|
"""Everything `vm wake` needs to decide whether, and when, to power on."""
|
|
|
|
rtc_now: datetime
|
|
alarm: datetime | None
|
|
aie: bool
|
|
rtc_en: bool | None
|
|
regs: dict[str, int]
|
|
|
|
@property
|
|
def armed(self) -> bool:
|
|
"""AIE on and registers decodable: a real board would wake."""
|
|
return self.aie and self.alarm is not None
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
"""JSON-ready view; epochs are the RTC read as UTC, like rtcwake does."""
|
|
out: dict[str, Any] = {
|
|
"rtc_now": _iso(self.rtc_now),
|
|
"rtc_now_epoch": int(self.rtc_now.timestamp()),
|
|
"aie": self.aie,
|
|
"rtc_en": self.rtc_en,
|
|
"armed": self.armed,
|
|
"regs": {k: f"0x{v:02x}" for k, v in self.regs.items()},
|
|
"alarm": None,
|
|
"alarm_epoch": None,
|
|
"lead_s": None,
|
|
}
|
|
if self.alarm is not None:
|
|
out["alarm"] = _iso(self.alarm)
|
|
out["alarm_epoch"] = int(self.alarm.timestamp())
|
|
out["lead_s"] = int((self.alarm - self.rtc_now).total_seconds())
|
|
return out
|
|
|
|
|
|
def _iso(when: datetime) -> str:
|
|
# The exact shape QEMU's `-rtc base=` accepts.
|
|
return when.strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
|
def decode_field(raw: int, bcd: bool) -> int | None:
|
|
"""One alarm byte; None when it is a don't-care (matches any value)."""
|
|
if raw & DONT_CARE == DONT_CARE:
|
|
return None
|
|
return (raw >> 4) * 10 + (raw & 0x0F) if bcd else raw
|
|
|
|
|
|
def decode_hour(raw: int, bcd: bool, h24: bool) -> int | None:
|
|
"""Hour alarm byte, honouring 12-hour mode (bit 7 = PM)."""
|
|
if raw & DONT_CARE == DONT_CARE:
|
|
return None
|
|
if h24:
|
|
return decode_field(raw, bcd)
|
|
value = decode_field(raw & 0x7F, bcd)
|
|
if value is None:
|
|
return None
|
|
return value % 12 + (12 if raw & 0x80 else 0)
|
|
|
|
|
|
def next_match(
|
|
now: datetime, sec: int | None, minute: int | None, hour: int | None
|
|
) -> datetime | None:
|
|
"""First instant >= now whose h:m:s matches; None if none within a day."""
|
|
start = now.replace(microsecond=0)
|
|
for step in range(24 * 3600 + 1):
|
|
t = start + timedelta(seconds=step)
|
|
if (
|
|
(sec is None or t.second == sec)
|
|
and (minute is None or t.minute == minute)
|
|
and (hour is None or t.hour == hour)
|
|
):
|
|
return t
|
|
return None
|
|
|
|
|
|
def build_report(
|
|
now: datetime, regs: dict[str, int], pm1_en: int | None
|
|
) -> AlarmReport:
|
|
"""Decode raw register values into an AlarmReport."""
|
|
reg_b = regs["reg_b"]
|
|
bcd = not reg_b & REG_B_BINARY
|
|
h24 = bool(reg_b & REG_B_SET_24H)
|
|
alarm = next_match(
|
|
now,
|
|
decode_field(regs["sec"], bcd),
|
|
decode_field(regs["min"], bcd),
|
|
decode_hour(regs["hour"], bcd, h24),
|
|
)
|
|
rtc_en = None if pm1_en is None else bool(pm1_en & PM1_EN_RTC_EN)
|
|
return AlarmReport(now, alarm, bool(reg_b & REG_B_AIE), rtc_en, regs)
|
|
|
|
|
|
def _hmp(stream: TextIO, line: str) -> str:
|
|
reply = command(stream, "human-monitor-command", **{"command-line": line})
|
|
if "error" in reply:
|
|
raise SystemExit(f"rtc_alarm: '{line}' failed: {reply['error']}")
|
|
return str(reply.get("return", ""))
|
|
|
|
|
|
def _port_in(stream: TextIO, size: str, port: int) -> int:
|
|
match = re.search(r"=\s*(0x[0-9a-fA-F]+)", _hmp(stream, f"i /{size} 0x{port:x}"))
|
|
if not match:
|
|
raise SystemExit(f"rtc_alarm: unreadable I/O port 0x{port:x}")
|
|
return int(match.group(1), 16)
|
|
|
|
|
|
def _cmos(stream: TextIO, index: int) -> int:
|
|
# Index then data. Never index 0x0C: reading register C clears the flags.
|
|
_hmp(stream, f"o /b 0x70 0x{index:02x}")
|
|
return _port_in(stream, "b", 0x71)
|
|
|
|
|
|
def rtc_now(stream: TextIO) -> datetime:
|
|
"""The guest RTC's current value, read as UTC."""
|
|
reply = command(stream, "qom-get", path="/machine", property="rtc-time")
|
|
if "error" in reply:
|
|
raise SystemExit(f"rtc_alarm: cannot read rtc-time: {reply['error']}")
|
|
tm = reply["return"]
|
|
return datetime(
|
|
tm["tm_year"] + 1900,
|
|
tm["tm_mon"] + 1,
|
|
tm["tm_mday"],
|
|
tm["tm_hour"],
|
|
tm["tm_min"],
|
|
tm["tm_sec"],
|
|
tzinfo=UTC,
|
|
)
|
|
|
|
|
|
def read_live(stream: TextIO) -> AlarmReport:
|
|
"""Read the alarm out of a STOPPED guest's emulated CMOS."""
|
|
status = command(stream, "query-status").get("return", {})
|
|
if status.get("running"):
|
|
# The CMOS index register is shared with the guest; poking it while
|
|
# the guest runs could corrupt one of its own RTC accesses.
|
|
raise SystemExit("rtc_alarm: guest is running; read only after it stopped")
|
|
regs = {name: _cmos(stream, index) for name, index in CMOS_ALARM_REGS.items()}
|
|
pm1_en = _port_in(stream, "h", PM_BASE + 2)
|
|
return build_report(rtc_now(stream), regs, pm1_en)
|
|
|
|
|
|
def _decode_cli(args: list[str]) -> AlarmReport:
|
|
now = datetime.fromtimestamp(int(args[0]), tz=UTC)
|
|
vals = [int(a, 0) for a in args[1:]]
|
|
regs = dict(zip(("sec", "min", "hour", "reg_b"), vals[:4], strict=True))
|
|
return build_report(now, regs, vals[4] if len(vals) > 4 else None)
|
|
|
|
|
|
def main() -> int:
|
|
argv = sys.argv[1:]
|
|
if argv[:1] == ["decode"] and len(argv) in (6, 7):
|
|
print(json.dumps(_decode_cli(argv[1:]).as_dict()))
|
|
return 0
|
|
if len(argv) != 2 or argv[0] not in ("read", "now"):
|
|
print(__doc__, file=sys.stderr)
|
|
return 2
|
|
if not os.path.exists(argv[1]):
|
|
print(f"rtc_alarm: no such socket: {argv[1]}", file=sys.stderr)
|
|
return 1
|
|
sock, stream = open_session(argv[1])
|
|
try:
|
|
if argv[0] == "now":
|
|
print(int(rtc_now(stream).timestamp()))
|
|
else:
|
|
print(json.dumps(read_live(stream).as_dict()))
|
|
finally:
|
|
stream.close()
|
|
sock.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|