359afb3a59
Infrastructure configs for GMKtec K11 (Docker, vLLM, LocalAI, ComfyUI, control-plane, gpu-fan agent, Server UI with CLI/file explorer/GPU fan curve). Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
5.7 KiB
Python
196 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""GPU fan control web UI + NVML daemon (single process)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import uvicorn
|
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from fan_controller import (
|
|
FanControlError,
|
|
FanController,
|
|
MAX_CURVE_POINTS,
|
|
MIN_CURVE_POINTS,
|
|
MIN_FAN_SPEED,
|
|
MAX_FAN_SPEED,
|
|
curve_to_dict,
|
|
parse_curve,
|
|
)
|
|
|
|
STACK_DIR = Path(__file__).resolve().parent
|
|
|
|
# Unified control-plane env (see stacks/control-plane/env_loader.py)
|
|
for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")):
|
|
if _cp not in sys.path and Path(_cp).exists():
|
|
sys.path.insert(0, _cp)
|
|
from env_loader import load_control_plane_env # noqa: E402
|
|
|
|
STATIC_DIR = STACK_DIR / "static"
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
handlers=[logging.StreamHandler(sys.stdout)],
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def _is_localhost_bind(host: str) -> bool:
|
|
return host.strip().lower() in ("127.0.0.1", "localhost", "::1")
|
|
|
|
|
|
def _resolve_host_port(env: dict[str, str]) -> tuple[str, int]:
|
|
host = env.get("GPU_FAN_API_HOST") or env.get("GPU_FAN_HOST", "0.0.0.0")
|
|
port_raw = env.get("GPU_FAN_API_PORT") or env.get("GPU_FAN_PORT", "8090")
|
|
return host, int(port_raw)
|
|
|
|
|
|
ENV = load_control_plane_env(STACK_DIR)
|
|
HOST, PORT = _resolve_host_port(ENV)
|
|
API_KEY = ENV.get("API_KEY", "")
|
|
CURVE_PATH = Path(ENV.get("CURVE_PATH", "/etc/gpu-fan/curve.json"))
|
|
POLL_INTERVAL = float(ENV.get("POLL_INTERVAL", "2.0"))
|
|
GPU_INDEX = int(ENV.get("GPU_INDEX", "0"))
|
|
DRY_RUN = ENV.get("DRY_RUN", "").lower() in ("1", "true", "yes")
|
|
|
|
controller = FanController(
|
|
curve_path=CURVE_PATH,
|
|
gpu_index=GPU_INDEX,
|
|
poll_interval=POLL_INTERVAL,
|
|
)
|
|
controller.dry_run = DRY_RUN
|
|
app = FastAPI(title="GPU Fan Control", version="1.0.0")
|
|
|
|
|
|
class CurvePoint(BaseModel):
|
|
temp: int = Field(ge=0, le=120)
|
|
speed: int = Field(ge=0, le=100)
|
|
|
|
|
|
class CurveUpdate(BaseModel):
|
|
points: list[CurvePoint] = Field(min_length=MIN_CURVE_POINTS, max_length=MAX_CURVE_POINTS)
|
|
|
|
|
|
class ModeUpdate(BaseModel):
|
|
mode: str
|
|
speed: int | None = Field(default=None, ge=MIN_FAN_SPEED, le=MAX_FAN_SPEED)
|
|
|
|
|
|
def require_auth(request: Request) -> None:
|
|
if not API_KEY:
|
|
return
|
|
key = request.headers.get("X-API-Key", "")
|
|
if key != API_KEY:
|
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
|
|
|
|
@app.get("/")
|
|
def index() -> FileResponse:
|
|
return FileResponse(STATIC_DIR / "index.html")
|
|
|
|
|
|
@app.get("/api/status")
|
|
def api_status(_: None = Depends(require_auth)) -> dict[str, Any]:
|
|
return controller.get_metrics()
|
|
|
|
|
|
@app.get("/api/curve")
|
|
def api_get_curve(_: None = Depends(require_auth)) -> dict[str, Any]:
|
|
points = controller.get_curve()
|
|
return {"points": [{"temp": t, "speed": s} for t, s in points]}
|
|
|
|
|
|
@app.put("/api/curve")
|
|
def api_put_curve(body: CurveUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
|
|
points = [(p.temp, p.speed) for p in body.points]
|
|
try:
|
|
parse_curve(curve_to_dict(points))
|
|
controller.save_curve_file(points)
|
|
controller.set_mode("curve")
|
|
except FanControlError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
|
|
|
|
|
|
@app.post("/api/mode")
|
|
def api_set_mode(body: ModeUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
|
|
try:
|
|
controller.set_mode(body.mode, body.speed)
|
|
if body.mode != "auto":
|
|
controller.update_once()
|
|
except FanControlError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return {"ok": True, "mode": controller.mode, "manual_speed": controller.manual_speed}
|
|
|
|
|
|
@app.post("/api/reload")
|
|
def api_reload(_: None = Depends(require_auth)) -> dict[str, Any]:
|
|
try:
|
|
controller.reload_curve()
|
|
except FanControlError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
|
|
|
|
|
|
def run_daemon_thread() -> None:
|
|
controller.run_loop()
|
|
|
|
|
|
def main() -> None:
|
|
if os.geteuid() != 0 and not DRY_RUN:
|
|
log.error("GPU fan control requires root (NVML write access). Run with sudo.")
|
|
sys.exit(1)
|
|
|
|
if not _is_localhost_bind(HOST) and not API_KEY:
|
|
log.error(
|
|
"API_KEY is required when GPU_FAN_HOST=%s (LAN/public bind). "
|
|
"Set API_KEY in .env",
|
|
HOST,
|
|
)
|
|
sys.exit(1)
|
|
|
|
controller.init_nvml()
|
|
|
|
def shutdown_handler(signum: int, _frame: object) -> None:
|
|
log.info("Received signal %s", signum)
|
|
if signum == signal.SIGHUP:
|
|
try:
|
|
controller.reload_curve()
|
|
except FanControlError as exc:
|
|
log.error("Curve reload failed: %s", exc)
|
|
return
|
|
controller.shutdown()
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGTERM, shutdown_handler)
|
|
signal.signal(signal.SIGINT, shutdown_handler)
|
|
signal.signal(signal.SIGHUP, shutdown_handler)
|
|
|
|
thread = threading.Thread(target=run_daemon_thread, daemon=True)
|
|
thread.start()
|
|
|
|
if _is_localhost_bind(HOST):
|
|
log.info("Web UI at http://127.0.0.1:%d", PORT)
|
|
else:
|
|
log.info("Web UI listening on 0.0.0.0:%d (LAN — API key required)", PORT)
|
|
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if os.environ.get("GPU_FAN_LEGACY_UI", "").lower() in ("1", "true", "yes"):
|
|
main()
|
|
else:
|
|
from fan_daemon import main as daemon_main
|
|
|
|
daemon_main()
|