Files
nvcurve/nvcurve/profiles/native.py
T
ARIA e148c83622 feat: experimental NVIDIA power control via RM ioctl interface
Adds an experimental power-cap mode using the undocumented RM ioctl
interface (based on panchovix's LACT PR #1205) to set power limits
below the VBIOS minimum (down to 30 W).

- hal/rm_power.py: RM ioctl power-cap read/write/reset + runtime probe
- limits.py: power_cap_mode (nvml/ioctl) with support detection
- config.py: persist power_cap_mode per GPU
- profiles: record/apply power_cap_mode
- server.py: POST /api/limits validates ioctl support (409 on failure)
- cli.py: profile save falls back to persisted mode
- client.py: power_cap_mode in Limits
- frontend: toggle + warning with panchovix attribution (LACT #1205)
- tests: test_rm_power.py (unit) + integration coverage
- Makefile: add test_rm_power.py to make test

Also includes automated linter reformatting (prettier, ruff, shellcheck,
isort, markdownlint) that the linter would apply anyway.
2026-09-17 22:44:27 +02:00

115 lines
4.1 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
# How power_limit_w is applied: "nvml" (default) or "ioctl" (experimental
# RM power control, permits values below the VBIOS minimum).
power_cap_mode: str | 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)
# Normalize the experimental power-cap mode; unknown values fall back to NVML.
if data.get("power_cap_mode") not in (None, "nvml", "ioctl"):
data["power_cap_mode"] = 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