#!/usr/bin/env python3
"""Igneum rig chassis fan controller (the founder's orders, 11 October 2026): the Octominer's four chassis fans driven
through the board's Super I/O (the nct6775 or it87 class, read through the kernel's hwmon). The fans are OFF by
default (duty 0, stopped) and spin only when needed: they start when the hottest card reaches 60 C core or 75 C
memory (or the CPU 65 C), at the lowest duty that brings it back under, climbing on temperature only (never on load),
and stop again only 8 C under the start line and after two minutes there. The BIOS fan mode is left as shipped:
this process takes the PWM outputs to manual while it runs and hands them back at exit. If an output cannot be
driven to 0 on this board (some chips floor at a minimum) the floor is read back, recorded and used as "off". The
four 750 W server PSUs' own fans are on no header and not controllable (RIG1.md: the noise floor). Root; system unit
igneum-rig-fans.service. State: /var/lib/igneum/fans.json every tick; log: /var/lib/igneum/fans.log (the chip, the
stopped state, every start and stop with the temperature that caused it, a line a minute while running), mirrored
beside the agent's heartbeat as ~igneum/.igneum-bench/fans.log. Settings may be overridden in /etc/igneum/fans.json:
start_core, start_mem, start_cpu, hysteresis_c, stop_after_s, min_duty, max, period.
Python 3 standard library only; the GPU reads are nvidia-smi (NVIDIA) and the amdgpu hwmon nodes (AMD)."""
import glob
import json
import os
import signal
import subprocess
import sys
import time

STATE = "/var/lib/igneum/fans.json"
LOG = "/var/lib/igneum/fans.log"
MIRROR = "/home/igneum/.igneum-bench/fans.log"
SETTINGS = "/etc/igneum/fans.json"
CHIPS = ("nct6775", "nct6776", "nct6779", "nct6791", "nct6792", "nct6793", "nct6795", "nct6796", "nct6797", "nct6798",
         "nct6799", "it8603", "it8613", "it8620", "it8622", "it8625", "it8628", "it8655", "it8665", "it8686", "it8688",
         "it8689", "it8695", "it8705", "it8712", "it8716", "it8718", "it8720", "it8721", "it8728", "it8732", "it8771",
         "it8772", "it8781", "it8782", "it8783", "it8786", "it8790", "it8792", "f71882fg", "f71869", "f71889",
         "w83627ehf", "w83627dhg", "w83667hg", "w83677hg")
cfg = {"start_core": 60, "start_mem": 75, "start_cpu": 65, "hysteresis_c": 8, "stop_after_s": 120, "min_duty": 25,
       "max": 100, "period": 5}
try:
    with open(SETTINGS) as f:
        cfg.update({k: v for k, v in json.load(f).items() if k in cfg})
except Exception:
    pass


def utc():
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def log(line):
    for p in (LOG, MIRROR):
        try:
            with open(p, "a") as f:
                f.write("%s %s\n" % (utc(), line))
        except Exception:
            pass


def read(p, default=None):
    try:
        with open(p) as f:
            return f.read().strip()
    except Exception:
        return default


def write(p, v):
    with open(p, "w") as f:
        f.write(str(v))


def find_chip():
    """The Super I/O's hwmon directory: by name after loading the two driver classes. Returns (name, dir) or (None,
    reason)."""
    for mod in ("nct6775", "it87"):
        subprocess.run(["modprobe", mod], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    names = {}
    for h in sorted(glob.glob("/sys/class/hwmon/hwmon*")):
        names[h] = read(os.path.join(h, "name"), "")
    for h, n in names.items():
        if n.startswith(CHIPS) and glob.glob(os.path.join(h, "pwm[0-9]")):
            return n, h
    return None, "no Super I/O hwmon with pwm outputs (hwmon names: %s)" % ", ".join(sorted(set(names.values())))


def pwm_outputs(h):
    """Every pwmN that has an enable node; a fan input of the same number is read beside it when present."""
    outs = []
    for p in sorted(glob.glob(os.path.join(h, "pwm[0-9]"))):
        n = os.path.basename(p)[3:]
        if os.path.exists(p + "_enable"):
            outs.append({"pwm": p, "enable": p + "_enable", "fan": os.path.join(h, "fan%s_input" % n), "n": n,
                         "orig_enable": read(p + "_enable"), "orig_duty": read(p), "floor": 0})
    return outs


def gpu_temps():
    """(hottest core, hottest memory, per-card list). NVIDIA by nvidia-smi; AMD by amdgpu's hwmon labels."""
    core, mem, cards = -1, -1, []
    try:
        out = subprocess.run(["nvidia-smi", "--query-gpu=name,temperature.gpu,temperature.memory",
                              "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=10).stdout
        for line in out.splitlines():
            parts = [x.strip() for x in line.split(",")]
            if len(parts) < 2:
                continue
            c = int(parts[1]) if parts[1].isdigit() else -1
            m = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else -1
            cards.append({"name": parts[0], "core": c, "mem": m})
            core, mem = max(core, c), max(mem, m)
    except Exception:
        pass
    for h in glob.glob("/sys/class/drm/card*/device/hwmon/hwmon*"):
        if read(os.path.join(h, "name"), "") != "amdgpu":
            continue
        c, m = -1, -1
        for t in glob.glob(os.path.join(h, "temp*_input")):
            label = read(t.replace("_input", "_label"), "")
            v = read(t, "0")
            v = int(v) // 1000 if v.lstrip("-").isdigit() else -1
            if label in ("edge", "junction"):
                c = max(c, v)
            elif label == "mem":
                m = max(m, v)
            elif not label:
                c = max(c, v)
        cards.append({"name": "amdgpu " + os.path.basename(h), "core": c, "mem": m})
        core, mem = max(core, c), max(mem, m)
    return core, mem, cards


def cpu_temp():
    t = -1
    for h in glob.glob("/sys/class/hwmon/hwmon*"):
        if read(os.path.join(h, "name"), "") in ("coretemp", "k10temp"):
            for p in glob.glob(os.path.join(h, "temp*_input")):
                v = read(p, "0")
                if v.lstrip("-").isdigit():
                    t = max(t, int(v) // 1000)
    return t


def set_duty(outs, pct):
    """Writes the duty to every output; a write of 0 that reads back higher is the chip's floor, recorded once."""
    for o in outs:
        try:
            write(o["pwm"], max(pct, o["floor"]) * 255 // 100)
            if pct == 0 and not o["floor"]:
                time.sleep(0.2)
                back = read(o["pwm"], "0")
                back = int(back) if back.isdigit() else 0
                if back > 0:
                    o["floor"] = max(1, back * 100 // 255)
                    log("pwm%s cannot be driven to 0: reads back %d (%d%%); that floor is this output's off" % (o["n"], back, o["floor"]))
        except Exception as ex:
            log("pwm%s: write failed: %s" % (o["n"], str(ex)[:80]))


def main():
    os.makedirs("/var/lib/igneum", exist_ok=True)
    chip, where = find_chip()
    if not chip:
        state = {"ts": utc(), "chip": None, "reason": where, "fallback": "the BIOS's own quiet fan mode (the founder's one BIOS visit)"}
        write(STATE, json.dumps(state))
        log("no controllable chassis PWM: %s; fallback %s" % (where, state["fallback"]))
        return 0
    outs = pwm_outputs(where)
    if not outs:
        state = {"ts": utc(), "chip": chip, "reason": "the chip exposes no pwm with an enable node", "fallback": "the BIOS's own quiet fan mode"}
        write(STATE, json.dumps(state))
        log("chip %s at %s has no controllable pwm; fallback %s" % (chip, where, state["fallback"]))
        return 0
    log("chip %s at %s; pwm outputs %s; start lines core %s mem %s cpu %s; hysteresis %s C, stop after %s s; fans off by default" % (
        chip, where, " ".join("pwm%s(enable %s duty %s)" % (o["n"], o["orig_enable"], o["orig_duty"]) for o in outs),
        cfg["start_core"], cfg["start_mem"], cfg["start_cpu"], cfg["hysteresis_c"], cfg["stop_after_s"]))
    running = [True]

    def restore(*_):
        running[0] = False

    signal.signal(signal.SIGTERM, restore)
    signal.signal(signal.SIGINT, restore)
    for o in outs:
        try:
            write(o["enable"], 1)      # manual
        except Exception as ex:
            log("pwm%s: cannot take manual control: %s" % (o["n"], str(ex)[:80]))
    duty = 0
    set_duty(outs, 0)
    log("fans stopped (duty 0%s)" % ("" if not any(o["floor"] for o in outs) else "; floors " + " ".join("pwm%s=%d%%" % (o["n"], o["floor"]) for o in outs if o["floor"])))
    on, under_since, last_log = False, None, 0
    try:
        while running[0]:
            core, mem, cards = gpu_temps()
            cpu = cpu_temp()
            # the hottest excess over its START line decides; the stop line is hysteresis_c under it
            excess = max(core - cfg["start_core"], mem - cfg["start_mem"], cpu - cfg["start_cpu"])
            cause = "core %s C" % core if core - cfg["start_core"] == excess else ("mem %s C" % mem if mem - cfg["start_mem"] == excess else "cpu %s C" % cpu)
            if not on and excess >= 0:
                on, duty, under_since = True, int(cfg["min_duty"]), None
                log("fans started at %d%%: %s reached the start line" % (duty, cause))
            elif on:
                if excess > 10:
                    duty += 10
                elif excess > 0:
                    duty += 3
                elif excess <= -cfg["hysteresis_c"]:
                    duty -= 1
                duty = max(int(cfg["min_duty"]), min(int(cfg["max"]), duty))
                if excess <= -cfg["hysteresis_c"]:
                    under_since = under_since or time.time()
                    if time.time() - under_since >= cfg["stop_after_s"]:
                        on, duty = False, 0
                        log("fans stopped: %s is %d C under the start line for %d s" % (cause, -excess, cfg["stop_after_s"]))
                else:
                    under_since = None
            set_duty(outs, duty)
            rpm = {}
            for o in outs:
                r = read(o["fan"], "")
                rpm["fan" + o["n"]] = int(r) if r.isdigit() else None
            state = {"ts": utc(), "chip": chip, "hwmon": where, "on": on, "duty_pct": duty, "gpu_core_c": core, "gpu_mem_c": mem,
                     "cpu_c": cpu, "cards": cards, "rpm": rpm, "start": {"core": cfg["start_core"], "mem": cfg["start_mem"], "cpu": cfg["start_cpu"]},
                     "hysteresis_c": cfg["hysteresis_c"], "floors": {"pwm" + o["n"]: o["floor"] for o in outs if o["floor"]}}
            try:
                write(STATE + ".tmp", json.dumps(state))
                os.replace(STATE + ".tmp", STATE)
            except Exception:
                pass
            now = time.time()
            if on and now - last_log >= 60:
                log("running at %d%%: gpu core %s mem %s cpu %s rpm %s" % (duty, core, mem, cpu, " ".join("%s=%s" % kv for kv in sorted(rpm.items()))))
                last_log = now
            elif not on and now - last_log >= 600:
                log("stopped: gpu core %s mem %s cpu %s" % (core, mem, cpu))
                last_log = now
            time.sleep(int(cfg["period"]))
    finally:
        for o in outs:
            try:
                if o["orig_duty"] is not None:
                    write(o["pwm"], o["orig_duty"])
                if o["orig_enable"] is not None:
                    write(o["enable"], o["orig_enable"])
            except Exception:
                pass
        log("controller stopped; pwm outputs handed back to the board's own mode")
    return 0


if __name__ == "__main__":
    sys.exit(main())
