#!/usr/bin/env python3
"""van-thermal — temperature + health monitor for the campervan router.

One small daemon that does three jobs off a single sysfs sample loop:
  1. publishes /run/van-thermal/state.json  (the Cockpit "Temps" card reads this,
     exactly like van-failover's state.json — no shelling out to `sensors` per refresh)
  2. warns to the journal on threshold crossings, with hysteresis so a sensor hovering
     on the line doesn't spam (journalctl -u van-thermal), and sends a Pushover alert
     on each *rising* crossing into warn/crit (same hysteresis debounces the phone)
  3. appends throttled CSV history to /var/log/van-thermal.csv with self-rotation

Sensors are resolved by hwmon *name* + *label* at runtime, never by hwmonN index
(that number is assigned at boot and is not stable). Besides temperatures a sensor
spec may set "kind": "fan" (alerts when the fan is commanded on but reads 0 RPM) or
"kind": "undervolt" (live rpi_volt alarm, plus the firmware's latched since-boot bit
so dips between samples still surface). Pushover credentials are shared
with van-battery (/etc/van-battery/pushover.json, 0600); missing/placeholder creds
disable sending but nothing else. Stdlib only.
"""

import json
import os
import socket
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

CONFIG_PATH = os.environ.get("VAN_THERMAL_CONFIG", "/etc/van-thermal/config.json")
STATE_DIR = Path("/run/van-thermal")
STATE_PATH = STATE_DIR / "state.json"
HWMON = Path("/sys/class/hwmon")
HOST = socket.gethostname()

DEFAULTS = {
    "sample_interval": 10,      # seconds between sysfs reads (alerting cadence)
    "log_interval": 60,         # seconds between CSV rows (history cadence)
    "log_path": "/var/log/van-thermal.csv",
    "log_max_bytes": 5 * 1024 * 1024,   # rotate to .1 past this, keep one old file
    # Lowest level whose rising crossing sends Pushover ("warn" or "crit"). Journal
    # logging happens at every crossing regardless of this.
    "pushover_level": "warn",
    # Shared with van-battery so there's a single secret to maintain.
    "credentials_path": "/etc/van-battery/pushover.json",
    "sensors": [
        {"name": "cpu",  "hwmon": "coretemp", "label": "Package id 0",
         "warn": 85, "crit": 95, "clear_margin": 5},
        {"name": "nvme", "hwmon": "nvme",     "label": "Composite",
         "warn": 65, "crit": 70, "clear_margin": 5},
    ],
}

LEVELS = ("ok", "warn", "crit")
ICONS = {"fan": "🌀", "undervolt": "⚡"}   # pushover title icons for non-temp kinds
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}


def log(msg, level="info"):
    # systemd journal severity prefixes (sd-daemon); shows up via journalctl -p.
    pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
    print(pri + msg, flush=True)


def load_creds(cfg):
    try:
        c = json.loads(Path(cfg["credentials_path"]).read_text())
        token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
        if token in PLACEHOLDERS or user in PLACEHOLDERS:
            return None
        return token, user
    except FileNotFoundError:
        return None
    except Exception as e:
        log(f"credentials {cfg['credentials_path']} unreadable ({e})", "warn")
        return None


def pushover(cfg, title, message, priority=0, attempts=3, retry_delay=15):
    creds = load_creds(cfg)
    if not creds:
        log(f"pushover skipped (no credentials): {title} — {message}", "warn")
        return False
    token, user = creds
    data = urllib.parse.urlencode({
        "token": token, "user": user, "title": title,
        "message": message, "priority": priority,
    }).encode()
    req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
    # Retry network failures (DNS not up yet at boot, WAN flap) — they block the
    # sample loop briefly, which is fine at this cadence. A non-200 means Pushover
    # rejected the request (bad token etc.); retrying won't change that.
    for attempt in range(1, attempts + 1):
        try:
            with urllib.request.urlopen(req, timeout=10) as resp:
                ok = resp.status == 200
            if not ok:
                log(f"pushover HTTP {resp.status}", "warn")
            return ok
        except Exception as e:
            last = attempt == attempts
            log(f"pushover send failed ({attempt}/{attempts}): {e}", "warn")
            if not last:
                time.sleep(retry_delay)
    return False


def load_config():
    cfg = dict(DEFAULTS)
    try:
        with open(CONFIG_PATH) as f:
            cfg.update(json.load(f))
    except FileNotFoundError:
        log(f"config {CONFIG_PATH} not found, using built-in defaults")
    except Exception as e:
        log(f"config {CONFIG_PATH} unreadable ({e}), using built-in defaults", "warn")
    return cfg


def find_hwmon(name):
    """Return the hwmon dir whose name matches, or None. Re-resolved on demand
    because the hwmonN index can shift across boots / module reloads."""
    for d in sorted(HWMON.glob("hwmon*")):
        try:
            if (d / "name").read_text().strip() == name:
                return d
        except OSError:
            continue
    return None


def resolve_hwmon(spec, cache):
    """Cached hwmon-dir lookup by name; re-resolved when the dir vanished."""
    d = cache.get(spec["hwmon"])
    if d is None or not d.exists():
        d = find_hwmon(spec["hwmon"])
        cache[spec["hwmon"]] = d
    return d


def read_hwmon_int(d, fname):
    try:
        return int((d / fname).read_text().strip())
    except (OSError, ValueError):
        return None


def read_temp(spec, cache):
    """Read one sensor's temperature in °C, or None if unavailable.

    spec: {hwmon, label}. Resolves hwmon dir + the tempN whose *_label matches,
    falling back to temp1 when the chip exposes no labels."""
    d = resolve_hwmon(spec, cache)
    if d is None:
        return None

    input_file = None
    want = spec.get("label")
    if want:
        for lbl in sorted(d.glob("temp*_label")):
            try:
                if lbl.read_text().strip() == want:
                    input_file = lbl.with_name(lbl.name.replace("_label", "_input"))
                    break
            except OSError:
                continue
    if input_file is None:
        input_file = d / "temp1_input"   # label not found / chip is label-less

    try:
        return int(input_file.read_text().strip()) / 1000.0
    except (OSError, ValueError):
        # hwmon may have re-enumerated; drop the cache so next pass re-resolves.
        cache[spec["hwmon"]] = None
        return None


def read_fan(spec, cache):
    """(rpm, pwm) for a pwmfan hwmon, either None when unavailable."""
    d = resolve_hwmon(spec, cache)
    if d is None:
        return None, None
    rpm = read_hwmon_int(d, "fan1_input")
    if rpm is None:
        cache[spec["hwmon"]] = None   # hwmon may have re-enumerated
        return None, None
    return rpm, read_hwmon_int(d, "pwm1")


def read_undervolt(spec, cache):
    """(now, since_boot) undervoltage flags.

    Live alarm from the rpi_volt hwmon; the firmware's latched since-boot bit
    (get_throttled bit 16) catches dips shorter than the sample interval."""
    now = None
    d = resolve_hwmon(spec, cache)
    if d is not None:
        v = read_hwmon_int(d, "in0_lcrit_alarm")
        if v is None:
            cache[spec["hwmon"]] = None
        else:
            now = bool(v)
    since_boot = None
    try:
        out = subprocess.run(["vcgencmd", "get_throttled"], capture_output=True,
                             text=True, timeout=5).stdout
        bits = int(out.split("=")[1], 16)
        since_boot = bool(bits & 0x10000)
        if now is None:
            now = bool(bits & 0x1)
    except Exception:
        pass
    return now, since_boot


def classify_fan(rpm, pwm, prev_level):
    """A fan commanded on (pwm > 0) reading 0 RPM is stalled/unplugged: first
    such sample is warn, a consecutive one escalates to crit. pwm == 0 with
    0 RPM is the firmware idling the fan on a cool SoC — that's ok."""
    if rpm is None:
        return prev_level
    if rpm > 0 or pwm is None or pwm == 0:
        return "ok"
    return "crit" if prev_level in ("warn", "crit") else "warn"


def classify_undervolt(now, since_boot, prev_level):
    """crit while actively under-volted; warn (sticky until reboot) once a dip
    has been latched, so a transient still gets one page + a yellow pill."""
    if now is None:
        return prev_level
    if now:
        return "crit"
    return "warn" if since_boot else "ok"


def classify(temp, spec, prev_level):
    """Level with hysteresis: step up at the threshold, step down only after
    dropping clear_margin below it, so a sensor on the line doesn't oscillate."""
    if temp is None:
        return prev_level
    warn, crit = spec["warn"], spec["crit"]
    margin = spec.get("clear_margin", 5)
    if temp >= crit:
        return "crit"
    if temp >= warn:
        return "crit" if prev_level == "crit" and temp > crit - margin else "warn"
    if temp >= warn - margin:
        return prev_level if prev_level in ("warn", "crit") else "ok"
    return "ok"


def announce(name, disp, old, new):
    """Log a level change to the journal. Returns the crossing direction
    ("rising" / "falling") so the caller can decide whether to page via
    Pushover, or None when the level is unchanged."""
    if old == new:
        return None
    rising = LEVELS.index(new) > LEVELS.index(old)
    sev = {"crit": "crit", "warn": "warn", "ok": "info"}[new]
    arrow = "rose to" if rising else "fell back to"
    log(f"{name} {arrow} {new.upper()} ({disp})", sev if rising else "info")
    return "rising" if rising else "falling"


def rotate_log(path, max_bytes):
    try:
        if path.exists() and path.stat().st_size > max_bytes:
            path.replace(path.with_suffix(path.suffix + ".1"))
    except OSError as e:
        log(f"log rotate failed: {e}", "warn")


def csv_columns(s):
    unit = {"temp": "c", "fan": "rpm", "undervolt": "uv"}.get(s.get("kind", "temp"), "v")
    return [f"{s['name']}_{unit}", f"{s['name']}_lvl"]


def csv_value(r):
    kind = r.get("kind", "temp")
    if kind == "fan":
        return "" if r["rpm"] is None else str(r["rpm"])
    if kind == "undervolt":
        return "" if r["now"] is None else str(int(r["now"]))
    return "" if r["temp"] is None else f"{r['temp']:.1f}"


def write_csv(cfg, readings):
    path = Path(cfg["log_path"])
    rotate_log(path, cfg["log_max_bytes"])
    header = ",".join(["time"] + sum([csv_columns(s) for s in cfg["sensors"]], []))
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        if path.exists():
            with open(path) as f:
                if f.readline().rstrip("\n") != header:
                    # Sensor set changed — rotate so columns stay aligned with the header.
                    path.replace(path.with_suffix(path.suffix + ".1"))
        new = not path.exists()
        with open(path, "a") as f:
            if new:
                f.write(header + "\n")
            row = [datetime.now(timezone.utc).isoformat(timespec="seconds")]
            for s in cfg["sensors"]:
                r = readings[s["name"]]
                row += [csv_value(r), r["level"]]
            f.write(",".join(row) + "\n")
    except OSError as e:
        log(f"csv write failed: {e}", "warn")


def write_state(readings):
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    payload = {
        "updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "sensors": readings,
    }
    tmp = STATE_PATH.with_suffix(".tmp")
    tmp.write_text(json.dumps(payload))
    tmp.replace(STATE_PATH)   # atomic swap so the Cockpit reader never sees a partial file


def main():
    cfg = load_config()
    cache = {}
    levels = {s["name"]: "ok" for s in cfg["sensors"]}
    last_log = 0.0
    # Minimum level (by severity index) whose rising crossing pages via Pushover.
    alert_idx = LEVELS.index(cfg["pushover_level"]) if cfg["pushover_level"] in LEVELS else 1
    log(f"van-thermal up: sampling every {cfg['sample_interval']}s, "
        f"logging every {cfg['log_interval']}s to {cfg['log_path']}; "
        f"Pushover on rising >= {LEVELS[alert_idx].upper()}")

    while True:
        readings = {}
        for s in cfg["sensors"]:
            name, kind = s["name"], s.get("kind", "temp")
            old = levels[name]

            if kind == "fan":
                rpm, pwm = read_fan(s, cache)
                new = classify_fan(rpm, pwm, old)
                disp = None if rpm is None else f"{rpm} RPM"
                detail = (f"fan turning at {rpm} RPM (pwm {pwm}/255)." if new == "ok" else
                          f"fan reads 0 RPM while commanded on (pwm {pwm}/255) — "
                          "stalled, blocked, or unplugged.")
                reading = {"kind": kind, "rpm": rpm, "pwm": pwm}
            elif kind == "undervolt":
                uv_now, uv_boot = read_undervolt(s, cache)
                new = classify_undervolt(uv_now, uv_boot, old)
                disp = None if uv_now is None else (
                    "UNDERVOLTAGE" if uv_now else
                    "dip since boot" if uv_boot else "supply ok")
                detail = {"crit": "supply voltage below threshold right now — check PSU and cabling.",
                          "warn": "an undervoltage dip was latched since boot (supply ok now; "
                                  "latch clears on reboot).",
                          "ok": "supply voltage ok."}[new]
                reading = {"kind": kind, "now": uv_now, "since_boot": uv_boot}
            else:
                temp = read_temp(s, cache)
                new = classify(temp, s, old)
                disp = None if temp is None else f"{temp:.1f}°C"
                thr = s["crit"] if new == "crit" else s["warn"]
                detail = (f"below warn ({s['warn']}°C)." if new == "ok" else
                          f"crossed the {new.upper()} threshold ({thr}°C); "
                          f"warn {s['warn']}, crit {s['crit']}.")
                reading = {"kind": kind, "temp": None if temp is None else round(temp, 1),
                           "warn": s["warn"], "crit": s["crit"]}

            if disp is not None:
                direction = announce(name, disp, old, new)
                if direction == "rising" and LEVELS.index(new) >= alert_idx:
                    icon = ICONS.get(kind, "🔥" if new == "crit" else "🌡")
                    pushover(cfg, f"{icon} {HOST}: {name} {new.upper()} — {disp}",
                             f"{name}: {detail}",
                             priority=1 if new == "crit" else 0)
                elif direction == "falling" and LEVELS.index(old) >= alert_idx:
                    # Recovery: page only when leaving a level we'd have paged about,
                    # so the phone that got the rising alert also gets the all-clear.
                    label = "NORMAL" if new == "ok" else new.upper()
                    pushover(cfg, f"✅ {HOST}: {name} back to {label} — {disp}",
                             f"{name}: {detail}", priority=0)

            levels[name] = new
            reading["level"] = new
            readings[name] = reading

        try:
            write_state(readings)
        except OSError as e:
            log(f"state write failed: {e}", "warn")

        now = time.monotonic()
        if now - last_log >= cfg["log_interval"]:
            write_csv(cfg, readings)
            last_log = now

        time.sleep(cfg["sample_interval"])


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(0)
