The fan curve previously only controlled fan index 0; secondary fans
stayed on driver control. The curve can now target all fans (new
default) or any individual fan(s).
Backend:
- hal/fans.py: get_num_fans() via nvmlDeviceGetNumFans; get_fan_info()
returns per-fan speeds; set_fan_speed() accepts a fan index list
(None = all fans; all-fans mode is lenient toward driver-locked
fans, explicit lists are strict); reset_fan() restores all fans.
- server.py: per-GPU fan_targets state; the poller applies the curve to
all target fans and logs write failures (once per distinct error);
activation validates targets against the hardware (stale indices fall
back to all fans); POST /api/fans accepts fans, POST /api/fans/speed
accepts a fan index, GET /api/fans returns num_fans/fans/fan_targets.
- Persistence format is now {"curve": ..., "fans": ...}; legacy
bare-curve entries migrate to "all fans" at startup.
- Profiles save/apply fan_targets alongside fan_curve.
- MonitoringSample carries per-fan speeds for live gauges.
Frontend:
- Fans tab: All / Fan 1 / Fan 2 / ... selector with live per-fan %;
the selection is applied together with the curve.
- Live monitor: per-fan gauges with sparklines for multi-fan GPUs.
109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
"""Native profile storage and schema."""
|
|
|
|
import glob
|
|
import json
|
|
import logging
|
|
import os
|
|
from dataclasses import asdict, dataclass
|
|
|
|
log = logging.getLogger("nvcurve.profiles.native")
|
|
|
|
|
|
@dataclass
|
|
class ProfileData:
|
|
name: str
|
|
gpu_name: str
|
|
curve_deltas: dict[str, int] # { "index": delta_khz }
|
|
mem_offset_mhz: int | None = None
|
|
power_limit_w: int | None = None
|
|
fan_curve: list[dict[str, int]] | None = None
|
|
# Fan indices controlled by fan_curve (0-based); None = all fans.
|
|
fan_targets: list[int] | None = None
|
|
|
|
|
|
def save_profile(profile_dir: str, data: ProfileData) -> str:
|
|
"""Save profile to JSON, sanitising the filename."""
|
|
try:
|
|
os.makedirs(profile_dir, exist_ok=True)
|
|
except OSError as exc:
|
|
raise RuntimeError(f"Cannot create profile dir {profile_dir}: {exc}") from exc
|
|
safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip()
|
|
if not safe_name:
|
|
safe_name = "Unnamed"
|
|
|
|
filepath = os.path.join(profile_dir, f"{safe_name}.json")
|
|
try:
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
json.dump(asdict(data), f, indent=2)
|
|
except OSError as exc:
|
|
raise RuntimeError(f"Cannot write profile {filepath}: {exc}") from exc
|
|
return filepath
|
|
|
|
|
|
def load_profile(filepath: str) -> ProfileData:
|
|
"""Load profile from JSON."""
|
|
try:
|
|
with open(filepath, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except FileNotFoundError:
|
|
raise
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise RuntimeError(f"Cannot read profile {filepath}: {exc}") from exc
|
|
# Migrate old field names.
|
|
if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data:
|
|
data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz")
|
|
# Drop removed fields so old profiles don't cause TypeError.
|
|
for obsolete in ("gpu_locked_min_mhz", "gpu_locked_max_mhz", "vram_p0_offset_mhz"):
|
|
data.pop(obsolete, None)
|
|
return ProfileData(**data)
|
|
|
|
|
|
def list_profiles(profile_dir: str) -> list[ProfileData]:
|
|
"""Return a list of all safely readable profiles."""
|
|
if not os.path.exists(profile_dir):
|
|
return []
|
|
profiles = []
|
|
for fp in glob.glob(os.path.join(profile_dir, "*.json")):
|
|
try:
|
|
profiles.append(load_profile(fp))
|
|
except Exception as exc:
|
|
log.debug("Skipping unreadable profile %s: %s", fp, exc)
|
|
# Sort alphabetically by name
|
|
profiles.sort(key=lambda p: p.name.lower())
|
|
return profiles
|
|
|
|
|
|
def rename_profile(profile_dir: str, old_name: str, new_name: str) -> bool:
|
|
"""Rename a profile: update the name field and move the file."""
|
|
old_safe = "".join(c for c in old_name if c.isalnum() or c in " _-()").strip()
|
|
new_safe = "".join(c for c in new_name if c.isalnum() or c in " _-()").strip()
|
|
if not new_safe:
|
|
return False
|
|
old_path = os.path.join(profile_dir, f"{old_safe}.json")
|
|
new_path = os.path.join(profile_dir, f"{new_safe}.json")
|
|
if not os.path.exists(old_path):
|
|
return False
|
|
try:
|
|
profile = load_profile(old_path)
|
|
profile.name = new_name
|
|
with open(new_path, "w", encoding="utf-8") as f:
|
|
json.dump(asdict(profile), f, indent=2)
|
|
if old_path != new_path:
|
|
os.remove(old_path)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def delete_profile(profile_dir: str, name: str) -> bool:
|
|
"""Delete a profile by name."""
|
|
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
|
|
filepath = os.path.join(profile_dir, f"{safe_name}.json")
|
|
if os.path.exists(filepath):
|
|
try:
|
|
os.remove(filepath)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
return False
|