#!/usr/bin/env python3
"""
thermal-watch: high-durability temperature/fan logger for diagnosing the e14
silent hard-freeze. Samples sysfs hwmon every INTERVAL seconds and fsync()s
every line to disk, so the LAST sample written before an abrupt freeze always
survives. A BOOT marker is written at startup; the gap between the final data
line and the next BOOT marker marks the freeze instant.

No external deps (stdlib only). Reads /sys/class/hwmon directly, resolving
sensors by NAME so it is immune to hwmonN renumbering across reboots.
"""
import os, sys, time, glob, signal

INTERVAL = 2.0                      # seconds between samples
LOG_DIR  = "/var/log/thermal-watch"
LOG      = os.path.join(LOG_DIR, "thermal.csv")
ROTATE_BYTES = 50 * 1024 * 1024    # rotate at 50 MB, keep one .1 backup

COLUMNS = ["iso", "mono_s", "cpu_pkg_c", "cpu_max_c", "fan_rpm",
           "acpitz_c", "nvme_max_c", "ram_c", "load1"]


def read_int(path):
    try:
        with open(path) as f:
            return int(f.read().strip())
    except Exception:
        return None


def read_str(path):
    try:
        with open(path) as f:
            return f.read().strip()
    except Exception:
        return ""


def discover():
    """Map sensor roles -> list of *_input file paths, by hwmon 'name'."""
    roles = {"coretemp": [], "acpi_fan": [], "acpitz": [], "nvme": [], "ram": []}
    for hw in sorted(glob.glob("/sys/class/hwmon/hwmon*")):
        name = read_str(os.path.join(hw, "name"))
        if name == "coretemp":
            roles["coretemp"] = sorted(glob.glob(os.path.join(hw, "temp*_input")))
        elif name == "acpi_fan":
            roles["acpi_fan"] = sorted(glob.glob(os.path.join(hw, "fan*_input")))
        elif name == "acpitz":
            roles["acpitz"] = sorted(glob.glob(os.path.join(hw, "temp*_input")))
        elif name == "nvme":
            roles["nvme"] = sorted(glob.glob(os.path.join(hw, "temp*_input")))
        elif name in ("spd5118", "spd"):  # DRAM SPD thermal sensor
            roles["ram"] = sorted(glob.glob(os.path.join(hw, "temp*_input")))
    return roles


def coretemp_pkg_and_max(paths):
    """coretemp tempX_label 'Package id 0' = package; max of the rest = hottest core."""
    pkg = None
    cores = []
    for p in paths:
        label = read_str(p.replace("_input", "_label"))
        v = read_int(p)
        if v is None:
            continue
        c = v / 1000.0
        if "Package" in label:
            pkg = c
        else:
            cores.append(c)
    return pkg, (max(cores) if cores else None)


def milli_max(paths):
    vals = [read_int(p) for p in paths]
    vals = [v / 1000.0 for v in vals if v is not None]
    return max(vals) if vals else None


def rotate_if_needed():
    try:
        if os.path.exists(LOG) and os.path.getsize(LOG) >= ROTATE_BYTES:
            os.replace(LOG, LOG + ".1")
    except Exception:
        pass


def write_line(fields):
    rotate_if_needed()
    line = ",".join("" if v is None else str(v) for v in fields) + "\n"
    # O_APPEND keeps writes atomic across the rotate; fsync forces it to platter.
    fd = os.open(LOG, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
    try:
        os.write(fd, line.encode())
        os.fsync(fd)
    finally:
        os.close(fd)


def fmt(v, nd=1):
    return None if v is None else round(v, nd)


def main():
    os.makedirs(LOG_DIR, exist_ok=True)
    # header once
    if not os.path.exists(LOG) or os.path.getsize(LOG) == 0:
        write_line(COLUMNS)

    boot_id = read_str("/proc/sys/kernel/random/boot_id")
    write_line(["#BOOT", time.strftime("%Y-%m-%dT%H:%M:%S%z"), "boot_id=" + boot_id])

    roles = discover()

    running = {"v": True}
    def stop(*_):
        running["v"] = False
    signal.signal(signal.SIGTERM, stop)
    signal.signal(signal.SIGINT, stop)

    while running["v"]:
        t0 = time.monotonic()
        pkg, cmax = coretemp_pkg_and_max(roles["coretemp"])
        fan = read_int(roles["acpi_fan"][0]) if roles["acpi_fan"] else None
        acpitz = milli_max(roles["acpitz"])
        nvme = milli_max(roles["nvme"])
        ram = milli_max(roles["ram"])
        try:
            load1 = os.getloadavg()[0]
        except Exception:
            load1 = None
        write_line([
            time.strftime("%Y-%m-%dT%H:%M:%S"),
            round(time.monotonic(), 1),
            fmt(pkg), fmt(cmax), fan,
            fmt(acpitz), fmt(nvme), fmt(ram), fmt(load1, 2),
        ])
        # steady cadence even if a sample took time
        dt = time.monotonic() - t0
        time.sleep(max(0.0, INTERVAL - dt))

    write_line(["#STOP", time.strftime("%Y-%m-%dT%H:%M:%S%z"), "clean shutdown"])


if __name__ == "__main__":
    main()
