Files
Jason WittyandClaude Opus 5.5 e59662b873 Document lovey setup: host, NixOS guest, Strata, pi client
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>
2026-10-26 13:05:17 -07:00

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()