README with overview, hardware, stack and rebuild order. Configs copied from the live machines at their real paths (proxmox/, nixos/), Strata's engine config and the pi client config with API keys removed. docs/ covers each layer, rebuild steps, benchmarks and a change log with the memory-instability and pi compaction-loop fixes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
134 lines
4.2 KiB
Python
Executable File
134 lines
4.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Radiator fan control for lovey: fans follow max(CPU curve, GPU curve).
|
|
|
|
CPU temp comes from coretemp. The GPUs are passed through to VM 200, so the
|
|
guest pushes its hottest GPU temp here over UDP every 2 s (gpu-temp-push in
|
|
the guest's NixOS config). On exit the fans go back to BIOS control.
|
|
"""
|
|
import glob, os, signal, socket, sys, time
|
|
|
|
FANS = [2, 3, 4, 5] # nct6798 headers with radiator fans; fan6 (AIO_PUMP) and fan7 (pump) stay on BIOS
|
|
LISTEN = ("192.168.1.53", 7777)
|
|
GPU_SOURCE = "192.168.1.67" # llm-nixos; only accept temps from it
|
|
VMID = 200
|
|
INTERVAL = 2.0
|
|
STALE = 15.0 # seconds without a GPU packet while the VM runs -> failsafe
|
|
FAILSAFE = 85 # % when GPU temp is unknown but the VM is up
|
|
MIN_PCT = 30
|
|
RAMP_DOWN = 2 # max % drop per interval (100 -> 30 in ~70 s)
|
|
ALPHA = 0.3 # temperature smoothing per interval (~6 s time constant)
|
|
|
|
# (temp C, fan %) points, linearly interpolated, flat outside the range
|
|
CPU_CURVE = [(50, 30), (70, 50), (85, 100)]
|
|
GPU_CURVE = [(45, 30), (60, 40), (75, 100)]
|
|
|
|
|
|
def hwmon(name):
|
|
for d in glob.glob("/sys/class/hwmon/hwmon*"):
|
|
with open(f"{d}/name") as f:
|
|
if f.read().strip() == name:
|
|
return d
|
|
sys.exit(f"hwmon {name} not found")
|
|
|
|
|
|
def read_int(path):
|
|
with open(path) as f:
|
|
return int(f.read())
|
|
|
|
|
|
def write(path, value):
|
|
with open(path, "w") as f:
|
|
f.write(str(value))
|
|
|
|
|
|
def curve(points, t):
|
|
if t <= points[0][0]:
|
|
return points[0][1]
|
|
for (t0, p0), (t1, p1) in zip(points, points[1:]):
|
|
if t <= t1:
|
|
return p0 + (p1 - p0) * (t - t0) / (t1 - t0)
|
|
return points[-1][1]
|
|
|
|
|
|
def vm_running():
|
|
try:
|
|
with open(f"/var/run/qemu-server/{VMID}.pid") as f:
|
|
os.kill(int(f.read()), 0)
|
|
return True
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
|
|
NCT = hwmon("nct6798")
|
|
CORETEMP = hwmon("coretemp")
|
|
CPU_TEMP = next(p.replace("_label", "_input") for p in glob.glob(f"{CORETEMP}/temp*_label")
|
|
if open(p).read().startswith("Package"))
|
|
|
|
|
|
def restore(*_):
|
|
for n in FANS:
|
|
try:
|
|
write(f"{NCT}/pwm{n}_enable", 5) # 5 = SmartFan IV, the BIOS curve
|
|
except OSError:
|
|
pass
|
|
sys.exit(0)
|
|
|
|
|
|
def main():
|
|
signal.signal(signal.SIGTERM, restore)
|
|
signal.signal(signal.SIGINT, restore)
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.bind(LISTEN)
|
|
sock.setblocking(False)
|
|
for n in FANS:
|
|
write(f"{NCT}/pwm{n}_enable", 1)
|
|
|
|
cpu_s = gpu_s = None
|
|
gpu_at = 0.0
|
|
pct = 100.0
|
|
last_log = 0.0
|
|
try:
|
|
while True:
|
|
while True: # drain queued GPU packets, keep the newest
|
|
try:
|
|
data, (addr, _) = sock.recvfrom(64)
|
|
except BlockingIOError:
|
|
break
|
|
if addr == GPU_SOURCE:
|
|
try:
|
|
g = float(data.decode().strip())
|
|
except ValueError:
|
|
continue
|
|
gpu_s = g if gpu_s is None else gpu_s + ALPHA * (g - gpu_s)
|
|
gpu_at = time.monotonic()
|
|
|
|
c = read_int(CPU_TEMP) / 1000
|
|
cpu_s = c if cpu_s is None else cpu_s + ALPHA * (c - cpu_s)
|
|
|
|
want = curve(CPU_CURVE, cpu_s)
|
|
if time.monotonic() - gpu_at < STALE:
|
|
want = max(want, curve(GPU_CURVE, gpu_s))
|
|
gpu_state = f"{gpu_s:.0f}C"
|
|
elif vm_running():
|
|
want = max(want, FAILSAFE)
|
|
gpu_state = "stale"
|
|
else:
|
|
gpu_state = "vm-off"
|
|
want = max(want, MIN_PCT)
|
|
pct = want if want > pct else max(want, pct - RAMP_DOWN)
|
|
|
|
for n in FANS:
|
|
write(f"{NCT}/pwm{n}", round(pct * 255 / 100))
|
|
|
|
if time.monotonic() - last_log >= 60:
|
|
rpm = " ".join(str(read_int(f"{NCT}/fan{n}_input")) for n in FANS)
|
|
print(f"cpu={cpu_s:.0f}C gpu={gpu_state} fans={pct:.0f}% rpm={rpm}", flush=True)
|
|
last_log = time.monotonic()
|
|
time.sleep(INTERVAL)
|
|
finally:
|
|
restore()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|