nvcurve with some fixes and better limits

This commit is contained in:
ARIA committed 2026-05-09 15:05:29 +02:00
commit 024dcbceb0
70 files changed
+18890

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

+17
View File
@@ -0,0 +1,17 @@
# Python
__pycache__/
*.py[cod]
.venv/
# Node
frontend/node_modules/
node_modules/
# IDE/Misc
.vscode/
.idea/
/tmp/
/dist/
/build/
*.egg-info/
.claude
+29
View File
@@ -0,0 +1,29 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.5.0] - 2026-03-23
### Changed
- **CLI Architecture Simplification**: The CLI has been decoupled from the FastAPI server and now operates as a stateless direct-HAL hardware administration tool. It no longer relies on the server for data reading or offset writing.
- **Consistent Privileges**: All CLI commands that interact with the hardware now explicitly require root privileges.
- **Background Daemon**: Added a new lightweight Unix socket daemon (`nvcurve daemon`) to handle auto-loading profiles on boot and managing the server's lifecycle.
- **Global Arguments**: Fixed global argument parsing in the CLI to allow global flags (like `--gpu`) to be placed after subcommands.
## [0.4.0] - 2026-03-17
### Added
- **Multi-GPU Support** *(experimental — untested on real multi-GPU hardware)*: The server now manages all detected NVIDIA GPUs simultaneously under a single process. Each GPU gets its own isolated state (write lock, monitor clients, curve clients, active profile). REST endpoints and WebSocket subscriptions accept a `gpu_index` parameter. A new `/api/gpus` endpoint enumerates all GPUs with name, index, UUID, and PCI bus ID.
- **GPU Selector in Web UI**: When multiple GPUs are present, the status bar shows a dropdown to switch the active GPU. Switching resets all pending edits, selection state, and live monitoring for the new target.
- **Default Profile**: Added the ability to designate a profile as the default — it is applied automatically on server startup.
- CLI: `nvcurve profile default <name>` to set, `nvcurve profile default --clear` to unset.
- The web UI shows a filled star on the default profile and lets you toggle it with a single click.
- The setting persists to `/etc/nvcurve/config.json` (created by `service install`). If the config file is absent, the setting is in-memory for the current session only.
- **Curve Flattening**: Selecting two or more points and clicking "Flatten to [anchor]" in the toolbar sets each selected point to a different offset, such that all land on the same effective frequency as the anchor point. The anchor is the last explicitly clicked point (highlighted with an amber halo on the graph); bulk selections (box, range, Ctrl+A) preserve the existing anchor.
- **Server-Optional Profile Commands**: `profile apply`, `profile list`, and `profile default` no longer require the server to be running. When the server is absent they fall back to direct HAL calls or config-file writes, escalating to root via `sudo` automatically — the same pattern already used by snapshot commands.
- **Automated Setup Check**: `nvcurve setup` runs a consolidated 4-step hardware compatibility check: NvAPI function probe → V/F curve baseline read → non-destructive write-verify → automatic state restore. The write-verify defaults to the last GPU-domain point (safe on all GPU generations); override with `--point` and `--delta`. Pass `--full-mask` if writes fail on older GPUs such as Pascal.
### Changed
- **Profile CLI syntax**: Profile commands now take the profile name as a positional argument instead of `--name` (e.g. `nvcurve profile apply balanced` instead of `nvcurve profile apply --name balanced`).
- **Improved Diagnostics**: The `read --diag` engine now reports driver version, VRAM totals, power limits, raw clock offsets, memory offset ranges, and raw boost masks in addition to the NvAPI function probe.
- **Offline Snapshots**: `snapshot save`, `restore`, and `list` bypass the server and fall back to direct HAL operations when the daemon is not running.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ekojsalim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+240
View File
@@ -0,0 +1,240 @@
<div align="center">
<img src="frontend/public/logo.svg" alt="NVCurve Logo" width="128" />
<h1>NVCurve</h1>
<p><strong>Linux NVIDIA GPU V/F Curve Editor & OC Tool</strong></p>
</div>
---
NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control to Linux. It calls undocumented NvAPI functions directly via `libnvidia-api.so`, giving you precise frequency offsets control. A Python CLI handles scripting and headless use; a React web UI provides interactive curve editing, profile management, and live hardware monitoring.
> [!WARNING]
> **This tool is experimental.** It has been verified on a range of NVIDIA GPUs and recent drivers, but compatibility is not guaranteed across all hardware and driver versions. The underlying NvAPI functions are undocumented and may change or disappear between driver releases.
>
> Read operations are generally safe. Write operations alter GPU operational parameters. **Follow the first-time setup steps below before applying any changes**, and proceed with caution.
![NVCurve curve editor](.github/screenshots/curve-editor.png)
## Features
- **Per-Point Curve Editing**: Independently adjust the frequency offset for any voltage point on the GPU's V/F curve.
- **Curve Flattening**: Select multiple points and flatten them to a common frequency in one click using anchor-point targeting.
- **Live Monitoring**: Tracks GPU voltage, clock speed, temperature, and power draw via NvAPI and NVML.
- **Profile Management**: Save, apply, and set a default (auto-apply on startup) profile. Profile commands work with or without the server running.
- **Multi-GPU Support** *(experimental)*: Manage multiple NVIDIA GPUs simultaneously from a single server instance; switch between GPUs in the web UI. Untested on real multi-GPU hardware — feedback welcome.
- **Modern Web UI**: Interactive curve graph, point table, real-time monitoring dashboard, and GPU selector, all in the browser.
## Prerequisites
- **OS**: Linux
- **GPU**: NVIDIA GPU (tested on RTX 5090, Blackwell architecture)
- **Python**: 3.12+
- **Privileges**: Root access is required for hardware interactions. The CLI will prompt for elevated privileges via `sudo` when needed.
## Installation
We recommend installing with [uv](https://docs.astral.sh/uv/getting-started/installation/):
```bash
uv tool install nvcurve
```
*For frontend development, use `pnpm install` and `pnpm run dev` in the `frontend/` directory.*
## Getting started
### Step 1 — Verify hardware compatibility
Before touching anything, confirm NvAPI is working correctly on your GPU and driver. **Do not skip this on an untested configuration.**
```bash
nvcurve setup
```
This runs four checks in sequence:
1. **NvAPI function probe** — verifies all required functions resolve in the driver
2. **Curve read** — reads and displays your current V/F curve as a baseline
3. **Write-verify** — writes `+5 MHz` to the last GPU-domain point, reads it back, and confirms the driver accepted it and no other points were unexpectedly modified
4. **Restore** — automatically restores the snapshot from before the test write
Only continue if `setup` reports **Compatible**.
> Override the test point or delta with `nvcurve setup --point N --delta 5`. Pass `--full-mask` if writes fail on older GPUs (Pascal and earlier).
### Step 2 — Launch the web UI
With compatibility confirmed, the web UI is the primary interface for curve editing, monitoring, and profile management:
```bash
nvcurve
```
This starts the backend server and opens the UI in your browser at `http://localhost:8042`.
![NVCurve web UI dashboard](.github/screenshots/dashboard.png)
To run the server in the background:
```bash
nvcurve serve start --detach
nvcurve serve status
nvcurve serve stop
```
### Step 3 — (Optional) Install as a systemd service
Register the nvcurve daemon as a systemd service so it starts automatically on boot:
```bash
nvcurve service install
```
The daemon handles auto-loading GPU profiles on boot. The web server is separate and starts on demand (`nvcurve serve start`). To also have the web server start automatically on boot, pass `--auto-serve`:
```bash
nvcurve service install --auto-serve
# optionally: --host 0.0.0.0 --port 8042
```
This enables and starts the service immediately. Manage it with:
```bash
nvcurve service start
nvcurve service stop
nvcurve service restart
nvcurve service status # shows daemon state + web server config
nvcurve service uninstall
```
To change the web server auto-start setting or address after install, use `service configure` — it updates the config and restarts the daemon in one step:
```bash
sudo nvcurve service configure --auto-serve
sudo nvcurve service configure --no-auto-serve
sudo nvcurve service configure --host 0.0.0.0 --port 8042
```
## Upgrading
```bash
uv tool upgrade nvcurve
```
If you are running nvcurve as a systemd service, restart it afterwards to pick up the new version:
```bash
nvcurve service restart
```
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for a complete history of changes.
## Web UI controls
### Curve editor
| Action | Result |
|---|---|
| Click point | Select (clears other selections) |
| Shift+click point | Add/remove point from selection |
| Ctrl/Cmd+A | Select all active points |
| Escape | Clear selection |
| Drag point | Stage a frequency offset edit |
| Drag point (multi-selection) | Moves all selected points together |
| Enter (single point selected) | Open inline input to type an exact offset |
| ↑ / ↓ arrow keys | Nudge selected point(s) ±1 MHz |
| Ctrl/Cmd + ↑ / ↓ | Nudge ±10 MHz |
| Tab / Shift+Tab | Step through points one by one |
| Shift+drag on background | Box select; draw a rubber-band rectangle |
| Drag on background | Pan the X axis |
| Alt+scroll | Zoom X axis around the cursor |
### Point table
| Action | Result |
|---|---|
| Click row | Select point |
| Ctrl/Cmd+click | Toggle point in/out of selection |
| Shift+click | Range select from last selected to clicked |
| Drag across rows | Range select by dragging |
| Select Before / Select After | Expand selection to all points before or after the currently selected one (appears when exactly one point is selected) |
### Toolbar
The **Global Offset** slider appears when all active points share a uniform delta. Dragging it stages that offset across every point simultaneously; equivalent to `nvcurve write --global` but interactive.
## CLI reference
The CLI is suited for scripting, headless systems, or quick one-off operations. All write commands support `--dry-run` to preview changes without applying them.
### Reading
```bash
nvcurve read # Condensed V/F curve
nvcurve read --full # All points
nvcurve read --json # JSON output
```
### Writing
> [!NOTE]
> If you use LACT or similar tools, disable them first. Concurrent writes will overwrite each other.
```bash
# Preview first
nvcurve write --global --delta 50 --dry-run
nvcurve write --point 80 --delta 100 --dry-run
# Apply
nvcurve write --global --delta 50
nvcurve write --point 80 --delta 100
nvcurve write --range 70-90 --delta 75
nvcurve write --reset # Reset all offsets to 0
```
Snapshots are saved automatically before each write. Manual snapshot management:
```bash
nvcurve snapshot save
nvcurve snapshot list
nvcurve snapshot restore
```
### Profiles
Profile commands work whether or not the server is running. Apply and default operations escalate to root automatically when bypassing the server.
```bash
nvcurve profile save balanced # Save current curve as a profile
nvcurve profile apply balanced # Apply a saved profile
nvcurve profile list # List profiles with active/default markers
nvcurve profile default balanced # Set profile to apply on server startup
nvcurve profile default --clear # Clear the startup default
nvcurve --gpu 1 profile default perf # Set default for a specific GPU (multi-GPU)
```
> [!NOTE]
> If you use LACT or similar tools and have a default profile set, disable the `lactd` service first — it applies its own curve on startup and will overwrite NVCurve's auto-applied profile:
> ```bash
> sudo systemctl disable --now lactd
> ```
### Diagnostics
```bash
nvcurve read --diag # Probe all NvAPI functions
nvcurve inspect --point 80 # Raw buffer fields for a point
nvcurve inspect --range 78-82
```
## Architecture
NVCurve has two components:
- **Python backend (`nvcurve/`)**: Talks directly to `libnvidia-api.so` (via ctypes) and `libnvidia-ml.so` to read and write hardware state. Exposes everything through a FastAPI REST + WebSocket server, keeping privileged operations isolated from the browser.
- **React frontend (`frontend/`)**: Runs in the browser and communicates with the backend over HTTP and WebSockets. Handles curve visualization, point editing, live monitoring, and profile management.
## Disclaimer
This software is provided as-is. The NvAPI functions it relies on are undocumented, unsupported officially by NVIDIA, and may change or break without notice between driver releases. NVCurve has been verified on a range of NVIDIA GPUs and recent drivers; behaviour on other hardware or driver versions is not guaranteed. Write operations alter GPU state. The authors accept no responsibility for hardware damage, system instability, or data loss resulting from the use of this software.
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NVCurve</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4877
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"d3": "^7.9.0",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1",
"@types/d3": "^7.4.3",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+3069
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@swc/core'
- esbuild
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#18181b" />
<path d="M7 23C7 23 11 15 14 15C17 15 18 20 21 20C24 20 26 9 26 9" stroke="#a78bfa" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="26" cy="9" r="2" fill="#e879f9" />
<circle cx="7" cy="23" r="2" fill="#818cf8" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+159
View File
@@ -0,0 +1,159 @@
import { useGpu } from './hooks/useGpu';
import { useCurve } from './hooks/useCurve';
import { useMonitor } from './hooks/useMonitor';
import { StatusBar } from './components/Monitor/StatusBar';
import { LiveMonitor } from './components/Monitor/LiveMonitor';
import { CurveEditor } from './components/CurveEditor/CurveEditor';
import { PointTable } from './components/PointTable/PointTable';
import { PerformancePanel } from './components/Limits/PerformancePanel';
import { PerformanceMonitor } from './components/Monitor/PerformanceMonitor';
import { ProfilePanel } from './components/Profiles/ProfilePanel';
import { api } from './api/client';
import { useCurveStore } from './store/curveStore';
import { Toaster } from 'sonner';
import { Loader, ChevronDown } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
export default function App() {
const gpuInfo = useGpu();
const { curve, wsStatus: curveWsStatus } = useCurve();
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor();
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = useCurveStore();
const [activeTab, setActiveTab] = useState<'curve' | 'performance'>('curve');
const [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu');
const [isProfileOpen, setIsProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
const currentVoltageMv = monitor?.voltage_mv ?? null;
function handleRefresh() {
api.curve(selectedGpuIndex).then(setCurve).catch(console.error);
}
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(event.target as Node)) {
setIsProfileOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
// Fetch initial active profile
api.profiles(selectedGpuIndex).then(data => setActiveProfile(data.active)).catch(console.error);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [selectedGpuIndex, setActiveProfile]);
// Worst connection status wins
const wsStatus =
monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected'
? 'disconnected'
: monitorWsStatus === 'connecting' || curveWsStatus === 'connecting'
? 'connecting'
: 'connected';
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
<Toaster theme="dark" position="top-right" />
<StatusBar gpuInfo={gpuInfo} wsStatus={wsStatus} monitor={monitor} />
{wsStatus !== 'connected' && (
<div className="bg-amber-500/10 border-b border-amber-500/20 text-amber-500 px-4 py-2 text-center text-sm font-medium flex justify-center items-center gap-2">
<Loader size={14} className="animate-spin" />
{wsStatus === 'connecting' ? 'Reconnecting to backend...' : 'Connection lost. Retrying...'}
</div>
)}
<main className="flex-1 p-4 flex flex-col gap-4 w-full max-w-[1600px] mx-auto">
{/* Tab Header and Profile Selector */}
<div className="flex justify-between items-end border-b border-zinc-800 pb-2">
<div className="flex gap-6">
<button
onClick={() => setActiveTab('curve')}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === 'curve' ? 'border-pink-500 text-zinc-100' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
Curve
</button>
<button
onClick={() => setActiveTab('performance')}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === 'performance' ? 'border-pink-500 text-zinc-100' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
Performance
</button>
</div>
<div className="relative" ref={profileRef}>
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center gap-2 text-zinc-400 hover:text-zinc-100 transition-colors text-sm font-medium pb-2 -mb-[9px]"
>
{activeProfile ? (
<>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 shrink-0" />
<span className="text-zinc-200">{activeProfile}</span>
</>
) : (
'Profiles'
)}
<ChevronDown size={14} className={isProfileOpen ? "rotate-180 transition-transform" : "transition-transform"} />
</button>
{isProfileOpen && (
<div className="absolute right-0 top-full mt-2 w-80 z-50 shadow-[0_8px_30px_rgb(0,0,0,0.8)] border border-zinc-700/50 rounded-lg overflow-hidden">
<ProfilePanel activeProfile={activeProfile} onProfileApplied={setActiveProfile} />
</div>
)}
</div>
</div>
{/* Main content area */}
<div className="flex flex-col gap-4 w-full">
{activeTab === 'curve' ? (
<>
<div className="flex gap-4 items-stretch w-full">
<div className="flex-1 min-w-0 flex flex-col">
{curve ? (
<CurveEditor
curve={curve}
activeDomain={activeDomain}
onDomainChange={setActiveDomain}
currentVoltageMv={currentVoltageMv}
currentClockMhz={monitor?.clock_mhz ?? null}
onRefresh={handleRefresh}
/>
) : (
<div className="bg-zinc-900 rounded-lg p-8 text-center text-zinc-500 h-full flex items-center justify-center">
Loading curve…
</div>
)}
</div>
<div className="w-80 shrink-0 flex flex-col">
<LiveMonitor monitor={monitor} history={monitorHistory} />
</div>
</div>
{curve && (
<div className="w-full">
<PointTable
points={curve.points.filter(p => p.domain === activeDomain)}
currentVoltageMv={activeDomain === 'gpu' ? currentVoltageMv : null}
readOnly={activeDomain === 'memory'}
/>
</div>
)}
</>
) : (
<div className="flex gap-4 items-start w-full">
<div className="flex-1 min-w-0">
<PerformancePanel />
</div>
<div className="w-80 shrink-0 flex flex-col">
<PerformanceMonitor monitor={monitor} history={monitorHistory} />
</div>
</div>
)}
</div>
</main>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData } from '../types';
async function get<T>(path: string, gpuIndex?: number): Promise<T> {
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
const res = await fetch(url);
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`GET ${url}: ${res.status} — ${text}`);
}
return res.json() as Promise<T>;
}
async function post<T = void>(path: string, body?: unknown, gpuIndex?: number): Promise<T> {
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
const res = await fetch(url, {
method: 'POST',
headers: body != null ? { 'Content-Type': 'application/json' } : {},
body: body != null ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`POST ${url}: ${res.status} — ${text}`);
}
// Some endpoints return no body (204)
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return res.json() as Promise<T>;
return undefined as unknown as T;
}
async function del<T = void>(path: string, gpuIndex?: number): Promise<T> {
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
const res = await fetch(url, { method: 'DELETE' });
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`DELETE ${url}: ${res.status} — ${text}`);
}
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return res.json() as Promise<T>;
return undefined as unknown as T;
}
export const api = {
gpus: () => get<GpuInfo[]>('/gpus'),
gpu: (gpuIndex: number) => get<GpuInfo>('/gpu', gpuIndex),
curve: (gpuIndex: number) => get<CurveState>('/curve', gpuIndex),
ranges: (gpuIndex: number) => get<Record<string, { min_khz: number; max_khz: number }>>('/ranges', gpuIndex),
voltage: (gpuIndex: number) => get<{ voltage_uv: number; voltage_mv: number }>('/voltage', gpuIndex),
monitor: (gpuIndex: number) => get<MonitoringSample>('/monitor', gpuIndex),
snapshots: (gpuIndex: number) => get<SnapshotInfo[]>('/snapshots', gpuIndex),
/** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */
writeDeltas: (deltas: Record<number, number>, gpuIndex: number) =>
post<{ ok: boolean; freq_warnings?: string[] }>('/curve/write', { deltas }, gpuIndex),
/** Reset all frequency deltas to zero. */
resetCurve: (gpuIndex: number) => post('/curve/reset', undefined, gpuIndex),
/** Get performance limits mapping */
limits: (gpuIndex: number) => get<LimitsState>('/limits', gpuIndex),
/** Set performance limits */
updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) =>
post('/limits', updates, gpuIndex),
/** Reset power limit and memory offset to hardware defaults */
resetLimits: (gpuIndex: number) => post('/limits/reset', undefined, gpuIndex),
/** Profile Management */
profiles: (gpuIndex: number) => get<{ profiles: ProfileData[], active: string | null, auto_load: string | null }>('/profiles', gpuIndex),
saveProfile: (name: string, gpuIndex: number) => post<{ ok: boolean; filepath: string }>('/profiles', { name }, gpuIndex),
applyProfile: (name: string, gpuIndex: number) => post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex),
deleteProfile: (name: string) => del(`/profiles/${encodeURIComponent(name)}`),
renameProfile: (oldName: string, newName: string) =>
post(`/profiles/${encodeURIComponent(oldName)}/rename`, { new_name: newName }),
/** Server config */
setAutoLoadProfile: (name: string | null, gpuIndex: number) =>
post<{ ok: boolean; auto_load_profile: string | null }>('/config', { auto_load_profile: name, gpu_index: gpuIndex }),
};
+61
View File
@@ -0,0 +1,61 @@
type MessageHandler<T> = (data: T) => void;
type StatusHandler = (status: 'connecting' | 'connected' | 'disconnected') => void;
export interface WsHandle {
close: () => void;
}
export function createWsConnection<T>(
path: string,
onMessage: MessageHandler<T>,
onStatus: StatusHandler,
gpuIndex: number = 0,
): WsHandle {
let ws: WebSocket | null = null;
let delay = 1000;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
function connect() {
if (stopped) return;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${protocol}://${location.host}${path}`;
onStatus('connecting');
ws = new WebSocket(url);
ws.onopen = () => {
delay = 1000;
onStatus('connected');
ws?.send(JSON.stringify({ action: 'subscribe', gpu_index: gpuIndex }));
};
ws.onmessage = (e) => {
try {
onMessage(JSON.parse(e.data as string) as T);
} catch {
// ignore malformed
}
};
ws.onclose = () => {
if (stopped) return;
onStatus('disconnected');
timer = setTimeout(connect, delay);
delay = Math.min(delay * 2, 8000);
};
ws.onerror = () => {
ws?.close();
};
}
connect();
return {
close() {
stopped = true;
if (timer) clearTimeout(timer);
ws?.close();
},
};
}
@@ -0,0 +1,975 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { Check, X, RotateCcw, RefreshCw } from 'lucide-react';
import { scaleLinear } from 'd3';
import type { VFPoint, CurveState } from '../../types';
import { CurveTooltip } from './CurveTooltip';
import { CurveToolbar } from './CurveToolbar';
import { ConfirmDialog } from '../common/ConfirmDialog';
import { voltExtent, refBaseMhz, detectClampedPoints } from '../../utils/curveHelpers';
import { useCurveStore } from '../../store/curveStore';
interface Props {
curve: CurveState;
activeDomain: 'gpu' | 'memory';
onDomainChange: (d: 'gpu' | 'memory') => void;
currentVoltageMv: number | null;
currentClockMhz: number | null;
onRefresh: () => void;
}
const MARGIN = { top: 16, right: 24, bottom: 48, left: 64 };
/** Drag-tooltip info (shown while actively dragging a point) */
interface DragInfo {
pointIndex: number;
/** Current pending delta kHz while dragging */
currentDeltaKhz: number;
/** SVG inner coords of the point, for tooltip positioning */
cx: number;
cy: number;
}
/** Box-select rubber-band rect (SVG inner coords) */
interface BoxRect { x0: number; y0: number; x1: number; y1: number }
export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltageMv, currentClockMhz, onRefresh }: Props) {
const readOnly = activeDomain === 'memory';
const [hoveredPoint, setHoveredPoint] = useState<VFPoint | null>(null);
const [dragInfo, setDragInfo] = useState<DragInfo | null>(null);
const [boxRect, setBoxRect] = useState<BoxRect | null>(null);
const [inlineInput, setInlineInput] = useState<{ pointIndex: number; value: string } | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [svgCursor, setSvgCursor] = useState<string>('default');
// ─── Dynamic SVG dimensions ──────────────────────────────────────────────
const [svgW, setSvgW] = useState(680);
const svgWRef = useRef(680);
svgWRef.current = svgW;
const INNER_W = svgW - MARGIN.left - MARGIN.right;
const [svgH, setSvgH] = useState(460);
const svgHRef = useRef(460);
svgHRef.current = svgH;
const INNER_H = svgH - MARGIN.top - MARGIN.bottom;
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
const w = Math.round(width);
const h = Math.min(Math.round(height), 500, Math.round(window.innerHeight * 0.58));
setSvgW(prev => prev === w ? prev : Math.max(300, w));
setSvgH(prev => prev === h ? prev : Math.max(200, h));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Store
const {
pendingDeltas,
selectedPoints,
anchorPoint,
stageEdit,
stageMultiEdit,
selectPoint,
selectRange,
clearSelection,
effectiveMhz,
applyEdits,
discardEdits,
resetAllDeltas,
hasNegativeFreqWarning,
} = useCurveStore();
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [dialog, setDialog] = useState<'apply' | 'reset' | null>(null);
async function handleApplyConfirm() {
setDialog(null);
setBusy(true);
setActionError(null);
try {
await applyEdits(onRefresh);
} catch (e) {
setActionError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
async function handleResetConfirm() {
setDialog(null);
setBusy(true);
setActionError(null);
try {
await resetAllDeltas(onRefresh);
} catch (e) {
setActionError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
const pts = useMemo(
() => curve.points.filter(p => p.domain === activeDomain),
[curve.points, activeDomain],
);
const clampedPoints = useMemo(() => detectClampedPoints(pts), [pts]);
// Clear selection and pending when switching domains
useEffect(() => {
clearSelection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDomain]);
// ─── X-axis zoom / viewport ──────────────────────────────────────────────
// Use a slightly larger padding to space things out better
const [xViewport, setXViewport] = useState<[number, number]>(() => voltExtent(pts, 40));
// Reset viewport when the set of points changes (curve refresh)
const ptsKey = pts.map((p) => p.index).join(',');
useEffect(() => {
setXViewport(voltExtent(pts, 40));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ptsKey]);
// Compute Y domain accounting for pending changes
const allEffective = pts.map((p) => effectiveMhz(p));
const allBase = pts.map((p) => refBaseMhz(p));
const freqMin = Math.min(...allBase, ...allEffective) - 50;
const freqMax = Math.max(...allBase, ...allEffective) + 50;
const xScale = scaleLinear().domain(xViewport).range([0, INNER_W]);
const yScale = scaleLinear().domain([freqMin, freqMax]).range([INNER_H, 0]);
// Keep scales in a ref so event handler closures always see the latest
const scalesRef = useRef({ xScale, yScale, xViewport, innerW: INNER_W });
scalesRef.current = { xScale, yScale, xViewport, innerW: INNER_W };
const xTicks = xScale.ticks(8);
const yTicks = yScale.ticks(6);
// ─── Zoom factor (for toolbar slider) ────────────────────────────────────
const [fullExtentMin, fullExtentMax] = voltExtent(pts);
const fullWidth = (fullExtentMax - fullExtentMin) + 80;
const currentWidth = xViewport[1] - xViewport[0];
const zoomFactor = Math.max(1, Math.min(10, fullWidth / currentWidth));
function handleZoomChange(factor: number) {
const pad = 40;
const newWidth = Math.max(20, fullWidth / factor);
const center = (xViewport[0] + xViewport[1]) / 2;
let newMin = center - newWidth / 2;
let newMax = center + newWidth / 2;
if (newMin < fullExtentMin - pad) { newMin = fullExtentMin - pad; newMax = newMin + newWidth; }
if (newMax > fullExtentMax + pad) { newMax = fullExtentMax + pad; newMin = newMax - newWidth; }
setXViewport([newMin, newMax]);
}
// Polylines
const visiblePts = pts.filter((p) => {
const v = p.volt_mv;
return v >= xViewport[0] && v <= xViewport[1];
});
const baseLine = visiblePts
.map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(refBaseMhz(p)).toFixed(1)}`)
.join(' ');
const effectiveLine = visiblePts
.map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(p.freq_mhz).toFixed(1)}`)
.join(' ');
const hasPending = pendingDeltas.size > 0;
const pendingLine = hasPending
? visiblePts
.map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(effectiveMhz(p)).toFixed(1)}`)
.join(' ')
: null;
// ─── Coordinate helpers ──────────────────────────────────────────────────
function svgToContainer(svgX: number, svgY: number): { x: number; y: number } | null {
const svg = svgRef.current;
const container = containerRef.current;
if (!svg || !container) return null;
const svgRect = svg.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const scaleX = svgRect.width / svgWRef.current;
const scaleY = svgRect.height / svgHRef.current;
return {
x: svgRect.left - containerRect.left + (svgX + MARGIN.left) * scaleX,
y: svgRect.top - containerRect.top + (svgY + MARGIN.top) * scaleY,
};
}
function clientToInner(clientX: number, clientY: number): { x: number; y: number } | null {
const svg = svgRef.current;
if (!svg) return null;
const rect = svg.getBoundingClientRect();
return {
x: (clientX - rect.left) * (svgWRef.current / rect.width) - MARGIN.left,
y: (clientY - rect.top) * (svgHRef.current / rect.height) - MARGIN.top,
};
}
// ─── SVG cursor ──────────────────────────────────────────────────────────
function handleSvgMouseMove(e: React.MouseEvent) {
if (isPanning.current) return;
const next = e.shiftKey ? 'crosshair' : 'default';
setSvgCursor(c => c === next ? c : next);
}
function handleSvgMouseLeave() {
if (!isPanning.current) setSvgCursor('default');
}
// ─── Point hover ─────────────────────────────────────────────────────────
const handleMouseEnter = useCallback((p: VFPoint) => {
setHoveredPoint(p);
}, []);
const handleMouseLeave = useCallback(() => {
setHoveredPoint(null);
}, []);
// ─── Drag state (refs — survive renders without triggering effects) ───────
const dragState = useRef<{
active: boolean;
pointIndex: number;
startY: number;
startDeltaKhz: number;
pointInitialDeltas: Map<number, number>;
} | null>(null);
/** True if the mouse moved meaningfully during the current mousedown */
const dragMoved = useRef(false);
// ─── Box select refs ─────────────────────────────────────────────────────
const boxStartRef = useRef<{ x: number; y: number } | null>(null);
const isBoxSelecting = useRef(false);
// ─── Pan refs ─────────────────────────────────────────────────────────────
const panState = useRef<{ startX: number; startY: number; startViewport: [number, number] } | null>(null);
const isPanning = useRef(false);
// ─── Point mousedown → start drag ────────────────────────────────────────
function handlePointMouseDown(e: React.MouseEvent, p: VFPoint) {
if (readOnly) return;
containerRef.current?.focus();
e.stopPropagation();
e.preventDefault();
const inner = clientToInner(e.clientX, e.clientY);
if (!inner) return;
const currentDelta = pendingDeltas.has(p.index)
? pendingDeltas.get(p.index)!
: p.delta_khz;
const initialDeltas = new Map<number, number>();
if (selectedPoints.has(p.index) && selectedPoints.size > 1) {
// Feature: Dragging a point in a multi-selection moves the whole selection
for (const index of selectedPoints) {
const pt = pts.find(x => x.index === index);
if (pt) {
initialDeltas.set(index, pendingDeltas.has(index) ? pendingDeltas.get(index)! : pt.delta_khz);
}
}
} else {
// Default: Just drag this point
initialDeltas.set(p.index, currentDelta);
}
dragState.current = {
active: true,
pointIndex: p.index,
startY: inner.y,
startDeltaKhz: currentDelta,
pointInitialDeltas: initialDeltas
};
dragMoved.current = false;
}
// ─── SVG background mousedown → pan OR shift+drag = box select ───────────
function handleSvgMouseDown(e: React.MouseEvent) {
containerRef.current?.focus();
if (e.button !== 0) return;
const inner = clientToInner(e.clientX, e.clientY);
if (!inner) return;
if (e.shiftKey) {
// Shift+drag = box select
isBoxSelecting.current = true;
boxStartRef.current = inner;
setBoxRect({ x0: inner.x, y0: inner.y, x1: inner.x, y1: inner.y });
} else {
// Plain drag = pan
isPanning.current = true;
setSvgCursor('grabbing');
panState.current = { startX: inner.x, startY: inner.y, startViewport: [...scalesRef.current.xViewport] as [number, number] };
}
}
// ─── Scroll wheel → zoom x-axis around cursor (Alt + scroll) ────────────
function handleWheel(e: React.WheelEvent) {
if (!e.altKey) return; // plain scroll → let page scroll normally
e.preventDefault();
const inner = clientToInner(e.clientX, e.clientY);
if (!inner) return;
const { xScale: sx, xViewport: vp } = scalesRef.current;
const zoomFactor = e.deltaY < 0 ? 1.15 : 1 / 1.15; // scroll up = zoom in
const mouseVolt = sx.invert(inner.x);
const [vpMin, vpMax] = vp;
const currentWidth = vpMax - vpMin;
const newWidth = Math.max(20, Math.min(voltExtent(pts)[1] - voltExtent(pts)[0] + 40, currentWidth / zoomFactor));
const anchorFrac = (mouseVolt - vpMin) / currentWidth;
let newMin = mouseVolt - anchorFrac * newWidth;
let newMax = mouseVolt + (1 - anchorFrac) * newWidth;
// Clamp to full extent
const [fullMin, fullMax] = voltExtent(pts);
const pad = 20;
if (newMin < fullMin - pad) { newMax += (fullMin - pad - newMin); newMin = fullMin - pad; }
if (newMax > fullMax + pad) { newMin -= (newMax - fullMax - pad); newMax = fullMax + pad; }
setXViewport([newMin, newMax]);
}
// ─── Global mousemove + mouseup ──────────────────────────────────────────
useEffect(() => {
function onMove(e: MouseEvent) {
// --- Point drag ---
if (dragState.current?.active) {
const inner = clientToInner(e.clientX, e.clientY);
if (!inner) return;
const ds = dragState.current;
const dyPx = inner.y - ds.startY;
// Consider moved if more than 3px
if (Math.abs(dyPx) > 3) dragMoved.current = true;
const { yScale: ys } = scalesRef.current;
const [yRangeBottom, yRangeTop] = ys.range() as [number, number];
const [yDomainBottom, yDomainTop] = ys.domain() as [number, number];
const pxPerMhz = (yRangeBottom - yRangeTop) / (yDomainTop - yDomainBottom);
const deltaMhz = -dyPx / pxPerMhz;
// Calculate the absolute delta for the primary dragged point
const primaryNewDeltaKhz = Math.round(ds.startDeltaKhz + deltaMhz * 1000);
const primaryClamped = Math.max(-1000_000, Math.min(1000_000, primaryNewDeltaKhz));
// Difference to apply to all other points in the selection
const validDeltaDiff = primaryClamped - ds.startDeltaKhz;
if (ds.pointInitialDeltas.size > 1) {
const edits = new Map<number, number>();
ds.pointInitialDeltas.forEach((initialDelta, index) => {
const newDelta = initialDelta + validDeltaDiff;
const clampedNewDelta = Math.max(-1000_000, Math.min(1000_000, newDelta));
edits.set(index, clampedNewDelta);
});
stageMultiEdit(edits);
} else {
stageEdit(ds.pointIndex, primaryClamped);
}
// Update drag tooltip info
const point = pts.find((p) => p.index === ds.pointIndex);
if (point) {
const cx = scalesRef.current.xScale(point.volt_mv);
const deltaChange = primaryClamped - point.delta_khz;
const cy = scalesRef.current.yScale(point.freq_mhz + deltaChange / 1000);
setDragInfo({ pointIndex: ds.pointIndex, currentDeltaKhz: primaryClamped, cx, cy });
}
return;
}
// --- Pan ---
if (isPanning.current && panState.current) {
const inner = clientToInner(e.clientX, e.clientY);
if (!inner) return;
const ps = panState.current;
// How many volts does 1 SVG-inner-px correspond to?
const voltPerPx = (ps.startViewport[1] - ps.startViewport[0]) / scalesRef.current.innerW;
const dxVolt = (inner.x - ps.startX) * voltPerPx;
const [fullMin, fullMax] = voltExtent(pts);
const pad = 20;
const vpWidth = ps.startViewport[1] - ps.startViewport[0];
let newMin = ps.startViewport[0] - dxVolt;
let newMax = ps.startViewport[1] - dxVolt;
// Clamp so we don't pan completely outside
if (newMin < fullMin - pad) { newMin = fullMin - pad; newMax = newMin + vpWidth; }
if (newMax > fullMax + pad) { newMax = fullMax + pad; newMin = newMax - vpWidth; }
setXViewport([newMin, newMax]);
return;
}
// --- Box select ---
if (isBoxSelecting.current && boxStartRef.current) {
const inner = clientToInner(e.clientX, e.clientY);
if (!inner) return;
setBoxRect({
x0: boxStartRef.current.x,
y0: boxStartRef.current.y,
x1: inner.x,
y1: inner.y,
});
}
}
function onUp(e: MouseEvent) {
// --- End drag ---
if (dragState.current?.active) {
dragState.current = null;
setDragInfo(null);
return;
}
// --- End pan ---
if (isPanning.current) {
isPanning.current = false;
setSvgCursor('default');
if (panState.current) {
const inner = clientToInner(e.clientX, e.clientY);
if (inner) {
const dx = Math.abs(inner.x - panState.current.startX);
const dy = Math.abs(inner.y - panState.current.startY);
if (dx < 3 && dy < 3) {
// It was just a click on the background, not a pan, so clear selection
clearSelection();
}
}
}
panState.current = null;
return;
}
// --- End box select ---
if (isBoxSelecting.current && boxStartRef.current) {
isBoxSelecting.current = false;
const inner = clientToInner(e.clientX, e.clientY);
if (inner) {
const finalRect = {
x0: boxStartRef.current.x,
y0: boxStartRef.current.y,
x1: inner.x,
y1: inner.y,
};
const minX = Math.min(finalRect.x0, finalRect.x1);
const maxX = Math.max(finalRect.x0, finalRect.x1);
const minY = Math.min(finalRect.y0, finalRect.y1);
const maxY = Math.max(finalRect.y0, finalRect.y1);
const sizeTrivial = Math.abs(finalRect.x1 - finalRect.x0) < 4
&& Math.abs(finalRect.y1 - finalRect.y0) < 4;
if (!sizeTrivial) {
const { xScale: sx, yScale: ys } = scalesRef.current;
const selected = pts
.filter((p) => {
const cx = sx(p.volt_mv);
const cy = ys(effectiveMhz(p));
return cx >= minX && cx <= maxX && cy >= minY && cy <= maxY;
})
.map((p) => p.index);
selectRange(selected);
} else {
clearSelection();
}
}
boxStartRef.current = null;
setBoxRect(null);
}
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
// Only re-register when structural deps change; scales are accessed via scalesRef
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pts, pendingDeltas, stageEdit, selectRange, clearSelection, effectiveMhz]);
return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col h-full">
{/* Header: title + domain toggle + action buttons */}
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800 shrink-0">
<div className="flex items-center gap-3">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">V/F Curve</span>
{/* Domain toggle */}
<div className="flex rounded overflow-hidden border border-zinc-700 text-xs">
{([{label: 'GPU', value: 'gpu'}, {label: 'Memory', value: 'memory'}] as const).map((d) => (
<button
key={d.value}
onClick={() => onDomainChange(d.value)}
className={`px-2.5 py-0.5 capitalize transition-colors ${
activeDomain === d.value
? 'bg-zinc-700 text-zinc-100'
: 'bg-zinc-900 text-zinc-500 hover:text-zinc-300'
}`}
>
{d.label}
</button>
))}
</div>
{readOnly && (
<span className="text-xs text-zinc-500 italic">read-only</span>
)}
</div>
<div className="flex items-center gap-2">
{!readOnly && hasPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
{pendingDeltas.size} pending
</span>
)}
{!readOnly && (
<>
<button
onClick={() => setDialog('apply')}
disabled={!hasPending || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Check size={12} />
Apply
</button>
<button
onClick={() => discardEdits()}
disabled={!hasPending || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<X size={12} />
Discard
</button>
<button
onClick={() => setDialog('reset')}
disabled={busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-red-900 text-zinc-400 hover:text-red-300 text-xs transition-colors"
>
<RotateCcw size={12} />
Reset
</button>
</>
)}
<button
onClick={onRefresh}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors"
>
<RefreshCw size={12} />
Refresh
</button>
</div>
</div>
{/* Banners */}
{hasNegativeFreqWarning() && (
<div className="px-3 py-1.5 bg-amber-900/40 border-b border-amber-700 text-amber-300 text-xs">
⚠ One or more pending deltas would produce a negative effective frequency. The driver will clamp to 0 MHz.
</div>
)}
{actionError && (
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
<span>⚠ {actionError}</span>
<button onClick={() => setActionError(null)} className="ml-2 text-red-400 hover:text-red-200">✕</button>
</div>
)}
{/* Confirm dialogs */}
{dialog === 'apply' && (
<ConfirmDialog
message={`Apply ${pendingDeltas.size} pending change${pendingDeltas.size !== 1 ? 's' : ''} to hardware?`}
detail="This will write the staged frequency deltas to the GPU via NvAPI. The changes take effect immediately."
confirmLabel="Apply"
onConfirm={handleApplyConfirm}
onCancel={() => setDialog(null)}
/>
)}
{dialog === 'reset' && (
<ConfirmDialog
message="Reset all frequency offsets to zero?"
detail="This will write zero delta to every V/F point on the GPU. Any pending staged changes will also be discarded."
confirmLabel="Reset"
isDestructive
onConfirm={handleResetConfirm}
onCancel={() => setDialog(null)}
/>
)}
<div className="px-3 pt-3">
<CurveToolbar
activePts={pts}
onResetZoom={() => setXViewport(voltExtent(pts))}
isZoomed={Math.abs((xViewport[1] - xViewport[0]) - (voltExtent(pts)[1] - voltExtent(pts)[0])) > 5}
readOnly={readOnly}
zoomFactor={zoomFactor}
onZoomChange={handleZoomChange}
/>
</div>
{/* Need tabIndex=0 to capture keyboard events */}
<div className="flex-1 min-h-0 relative focus:outline-none px-3 pt-2 pb-3 flex flex-col justify-center"
ref={containerRef}
tabIndex={0}
onKeyDown={(e) => {
if (inlineInput) {
if (e.key === 'Escape') setInlineInput(null);
return; // Let the input handle it
}
if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
selectRange(pts.map(p => p.index));
return;
}
if (e.key === 'Escape') {
e.preventDefault();
clearSelection();
return;
}
if (e.key === 'Tab') {
e.preventDefault();
if (pts.length === 0) return;
const currentSelected = Array.from(selectedPoints);
if (currentSelected.length === 0) {
selectPoint(pts[0].index);
} else {
const lastSelected = e.shiftKey ? Math.min(...currentSelected) : Math.max(...currentSelected);
const idx = pts.findIndex(p => p.index === lastSelected);
if (idx >= 0) {
let nextIdx = e.shiftKey ? idx - 1 : idx + 1;
if (nextIdx < 0) nextIdx = pts.length - 1;
if (nextIdx >= pts.length) nextIdx = 0;
selectPoint(pts[nextIdx].index);
}
}
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
if (selectedPoints.size === 0) return;
const changeMhz = e.key === 'ArrowUp' ? 1 : -1;
const multiplier = (e.ctrlKey || e.metaKey) ? 10 : 1;
const changeKhz = changeMhz * multiplier * 1000;
const edits = new Map<number, number>();
for (const idx of selectedPoints) {
const p = pts.find(pt => pt.index === idx);
if (p) {
const current = pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz;
const clamped = Math.max(-500_000, Math.min(500_000, current + changeKhz));
edits.set(idx, clamped);
}
}
stageMultiEdit(edits);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (selectedPoints.size === 1) {
const idx = Array.from(selectedPoints)[0];
const p = pts.find(pt => pt.index === idx);
if (p) {
const initVal = (pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz) / 1000;
setInlineInput({ pointIndex: idx, value: initVal.toFixed(1) });
// We'll focus the input in an effect
}
}
return;
}
}}
>
<svg
ref={svgRef}
viewBox={`0 0 ${svgW} ${svgH}`}
width="100%"
height={svgH}
className="block"
style={{ fontFamily: 'monospace', cursor: svgCursor, userSelect: 'none' }}
onMouseDown={handleSvgMouseDown}
onMouseMove={handleSvgMouseMove}
onMouseLeave={handleSvgMouseLeave}
onWheel={handleWheel}
>
<g transform={`translate(${MARGIN.left},${MARGIN.top})`}>
<defs>
<clipPath id="chart-clip">
<rect x={0} y={0} width={INNER_W + 1} height={INNER_H + 1} />
</clipPath>
</defs>
{/* Grid */}
{xTicks.map((t) => (
<line key={t} x1={xScale(t)} x2={xScale(t)} y1={0} y2={INNER_H} stroke="#27272a" strokeWidth={1} />
))}
{yTicks.map((t) => (
<line key={t} x1={0} x2={INNER_W} y1={yScale(t)} y2={yScale(t)} stroke="#27272a" strokeWidth={1} />
))}
{/* X axis */}
{xTicks.map((t) => (
<g key={t} transform={`translate(${xScale(t)},${INNER_H})`}>
<line y2={5} stroke="#52525b" />
<text y={18} textAnchor="middle" fontSize={10} fill="#71717a">{t.toFixed(0)}</text>
</g>
))}
{/* Y axis */}
{yTicks.map((t) => (
<g key={t} transform={`translate(0,${yScale(t)})`}>
<line x2={-5} stroke="#52525b" />
<text x={-10} textAnchor="end" dominantBaseline="middle" fontSize={10} fill="#71717a">{t.toFixed(0)}</text>
</g>
))}
{/* Axis labels */}
<text x={INNER_W / 2} y={INNER_H + 40} textAnchor="middle" fontSize={11} fill="#52525b">Voltage (mV)</text>
<text x={-INNER_H / 2} y={-50} textAnchor="middle" fontSize={11} fill="#52525b" transform="rotate(-90)">Frequency (MHz)</text>
{/* Border */}
<rect x={0} y={0} width={INNER_W} height={INNER_H} fill="none" stroke="#3f3f46" strokeWidth={1} />
<g clipPath="url(#chart-clip)">
{/* Base curve */}
<polyline points={baseLine} fill="none" stroke="#7c3aed" strokeWidth={1.5} strokeOpacity={0.4} strokeLinejoin="round" />
{/* Confirmed effective curve */}
<polyline points={effectiveLine} fill="none" stroke="#34d399" strokeWidth={2} strokeLinejoin="round" />
{/* Pending effective curve */}
{pendingLine && (
<polyline
points={pendingLine}
fill="none"
stroke="#22d3ee"
strokeWidth={2}
strokeDasharray="6 3"
strokeLinejoin="round"
strokeOpacity={0.85}
/>
)}
{/* Current voltage / clock crosshairs — GPU domain only */}
{!readOnly && currentVoltageMv != null && xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W && (
<line
x1={xScale(currentVoltageMv)} x2={xScale(currentVoltageMv)}
y1={0} y2={INNER_H}
stroke="#facc15" strokeWidth={0.5} strokeDasharray="4 4" strokeOpacity={0.3}
/>
)}
{!readOnly && currentClockMhz != null && yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && (
<line
x1={0} x2={INNER_W}
y1={yScale(currentClockMhz)} y2={yScale(currentClockMhz)}
stroke="#facc15" strokeWidth={0.5} strokeDasharray="4 4" strokeOpacity={0.3}
/>
)}
{!readOnly && currentVoltageMv != null && currentClockMhz != null &&
xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W &&
yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && (
<circle
cx={xScale(currentVoltageMv)}
cy={yScale(currentClockMhz)}
r={3.5}
fill="#fde047"
stroke="#18181b"
strokeWidth={1}
/>
)}
{pts.map((p) => {
if (p.volt_mv < xViewport[0] - 5 || p.volt_mv > xViewport[1] + 5) return null;
const cx = xScale(p.volt_mv);
/** Confirmed position (hardware state — VFP effective frequency) */
const confirmedCy = yScale(p.freq_mhz);
/** Staged/pending position (what will be applied) */
const pendingCy = yScale(effectiveMhz(p));
const hasPendingEdit = pendingDeltas.has(p.index);
/**
* The "main" interactive circle sits at the pending position when
* there's a staged edit — that's the point the user is working with.
* Otherwise it sits at the confirmed position.
*/
const mainCy = hasPendingEdit ? pendingCy : confirmedCy;
const ghostCy = hasPendingEdit ? confirmedCy : null; // dim ring showing where it was
const isHovered = hoveredPoint?.index === p.index;
const isSelected = selectedPoints.has(p.index);
const isDragging = dragInfo?.pointIndex === p.index;
const isAnchor = isSelected && selectedPoints.size >= 2 && p.index === anchorPoint;
let fill = readOnly ? '#6366f1' : '#34d399';
if (!readOnly && hasPendingEdit && !isSelected) fill = '#22d3ee';
if (!readOnly && isSelected) fill = '#22d3ee';
// Tweaked radius for less bloated flat regions
const r = isDragging ? 5.5 : isHovered || isSelected || hasPendingEdit ? 4.5 : 2.5;
return (
<g key={p.index}>
{/* Anchor ring — amber outer halo marking the flatten reference point */}
{isAnchor && (
<circle cx={cx} cy={mainCy} r={r + 3.5}
fill="none" stroke="#f59e0b" strokeWidth={1.5} strokeOpacity={0.85} />
)}
{/* Dim ring at confirmed position (only visible when there's a pending edit) */}
{!readOnly && ghostCy !== null && Math.abs(ghostCy - mainCy) > 0.5 && (
<circle cx={cx} cy={ghostCy} r={3}
fill="none" stroke="#34d399" strokeWidth={1} strokeOpacity={0.4} />
)}
{/* Drop-line from confirmed → pending while dragging */}
{!readOnly && isDragging && ghostCy !== null && Math.abs(ghostCy - mainCy) > 1 && (
<line x1={cx} y1={ghostCy} x2={cx} y2={mainCy}
stroke="#22d3ee" strokeWidth={1} strokeDasharray="3 2" strokeOpacity={0.5} />
)}
{/* Main circle */}
<circle
cx={cx}
cy={mainCy}
r={r}
fill={fill}
fillOpacity={readOnly ? 0.7 : 1}
stroke={!readOnly && (isDragging || isSelected || isHovered) ? '#fff' : 'none'}
strokeWidth={isDragging ? 2 : isSelected ? 2 : 1}
style={{ cursor: readOnly ? 'default' : 'ns-resize', transition: 'r 0.08s' }}
onMouseEnter={() => handleMouseEnter(p)}
onMouseLeave={handleMouseLeave}
onMouseDown={readOnly ? undefined : (e) => handlePointMouseDown(e, p)}
onClick={readOnly ? undefined : (e) => {
if (dragMoved.current) return;
selectPoint(p.index, e.shiftKey);
}}
/>
</g>
);
})}
</g>
{/* Box-select rubber band */}
{boxRect && (
<rect
x={Math.min(boxRect.x0, boxRect.x1)}
y={Math.min(boxRect.y0, boxRect.y1)}
width={Math.abs(boxRect.x1 - boxRect.x0)}
height={Math.abs(boxRect.y1 - boxRect.y0)}
fill="#22d3ee" fillOpacity={0.08}
stroke="#22d3ee" strokeWidth={1} strokeDasharray="4 2" strokeOpacity={0.6}
pointerEvents="none"
/>
)}
</g>
</svg>
{/* Inline Input Overlay */}
{inlineInput && (() => {
const pt = pts.find(p => p.index === inlineInput.pointIndex);
if (!pt) return null;
const cx = scalesRef.current.xScale(pt.volt_mv);
const currentDelta = pendingDeltas.has(pt.index) ? pendingDeltas.get(pt.index)! : pt.delta_khz;
const deltaChange = currentDelta - pt.delta_khz;
const cy = scalesRef.current.yScale(pt.freq_mhz + deltaChange / 1000);
const pos = svgToContainer(cx, cy);
if (!pos) return null;
return (
<div
style={{
position: 'absolute',
left: pos.x - 40,
top: pos.y - 12,
zIndex: 100,
}}
>
<input
autoFocus
type="number"
step="1"
value={inlineInput.value}
onChange={(e) => setInlineInput({ ...inlineInput, value: e.target.value })}
onBlur={() => setInlineInput(null)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const mhz = parseFloat(inlineInput.value);
if (!isNaN(mhz) && isFinite(mhz)) {
const clamped = Math.max(-500_000, Math.min(500_000, Math.round(mhz * 1000)));
stageEdit(pt.index, clamped);
}
setInlineInput(null);
// Refocus the container
containerRef.current?.focus();
}
if (e.key === 'Escape') {
setInlineInput(null);
containerRef.current?.focus();
}
}}
className="w-20 bg-zinc-800 text-cyan-300 rounded px-1.5 py-0.5 border-2 border-cyan-500 shadow-lg outline-none text-xs font-mono text-center"
/>
</div>
);
})()}
{/* Hover / Active tooltip */}
{(() => {
if (dragInfo) return null;
let tooltipPt = hoveredPoint;
if (!tooltipPt && selectedPoints.size === 1) {
tooltipPt = pts.find(p => p.index === Array.from(selectedPoints)[0]) || null;
}
if (!tooltipPt) return null;
return (
<CurveTooltip
point={tooltipPt}
pendingDeltaKhz={pendingDeltas.get(tooltipPt.index)}
isClamped={clampedPoints.has(tooltipPt.index)}
/>
);
})()}
{/* Drag tooltip — shown while actively dragging */}
{dragInfo && (() => {
const point = pts.find((p) => p.index === dragInfo.pointIndex);
if (!point) return null;
const deltaMhz = dragInfo.currentDeltaKhz / 1000;
const deltaChange = dragInfo.currentDeltaKhz - point.delta_khz;
const effMhz = point.freq_mhz + deltaChange / 1000;
return (
<div
style={{
position: 'absolute',
right: 16,
bottom: 16,
pointerEvents: 'none',
zIndex: 50,
width: 164,
}}
className="bg-zinc-800 border border-cyan-500/50 rounded-md p-2 text-xs shadow-xl"
>
<div className="text-zinc-400 mb-1 flex items-center gap-1">
<span className="text-cyan-400">↕</span> Point {point.index}
<span className="ml-auto text-zinc-500">{point.volt_mv.toFixed(0)} mV</span>
</div>
<div className={`font-semibold ${deltaMhz > 0 ? 'text-cyan-400' : deltaMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}`}>
Δ {deltaMhz > 0 ? '+' : ''}{deltaMhz.toFixed(1)} MHz
</div>
<div className="text-cyan-200 font-semibold">
→ {effMhz.toFixed(0)} MHz
</div>
</div>
);
})()}
</div>
</div>
);
}
@@ -0,0 +1,130 @@
import { useState, useMemo, useEffect } from 'react';
import { ZoomIn, RotateCcw, Minus } from 'lucide-react';
import { useCurveStore } from '../../store/curveStore';
import type { VFPoint } from '../../types';
interface Props {
/** All curve points — used by global offset slider */
activePts: VFPoint[];
/** Called to reset the x-axis zoom to default */
onResetZoom: () => void;
/** True if the chart is currently zoomed in */
isZoomed: boolean;
/** True when viewing a read-only domain (memory) */
readOnly?: boolean;
/** Current zoom factor (1 = no zoom) */
zoomFactor: number;
/** Called when user changes zoom via slider */
onZoomChange: (factor: number) => void;
}
export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomFactor, onZoomChange }: Props) {
const { pendingDeltas, selectedPoints, anchorPoint, curve, stageRangeEdit, flattenToAnchor } = useCurveStore();
const [offsetMhz, setOffsetMhz] = useState(0);
const uniformDeltaMhz = useMemo(() => {
if (activePts.length === 0) return 0;
const firstD = pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
const uniform = activePts.every((p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD);
return uniform ? firstD / 1000 : null;
}, [activePts, pendingDeltas]);
useEffect(() => {
if (uniformDeltaMhz !== null) {
setOffsetMhz(uniformDeltaMhz);
}
}, [uniformDeltaMhz]);
function handleOffsetChange(mhz: number) {
setOffsetMhz(mhz);
stageRangeEdit(activePts, Math.round(mhz * 1000));
}
return (
<div className="flex flex-wrap items-center gap-2 px-1 pb-2">
{/* Zoom control */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40" title="Zoom x-axis (Alt+scroll also works)">
<ZoomIn size={11} className="text-zinc-500 shrink-0" />
<input
type="range"
min={1}
max={10}
step={0.1}
value={zoomFactor}
onChange={(e) => onZoomChange(Number(e.target.value))}
className="w-20 h-1 cursor-pointer accent-cyan-400"
/>
<span className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? 'text-cyan-400' : 'text-zinc-600'}`}>
{zoomFactor.toFixed(1)}×
</span>
{isZoomed && (
<button
onClick={onResetZoom}
title="Reset zoom"
className="text-zinc-500 hover:text-zinc-300 transition-colors ml-0.5"
>
<RotateCcw size={11} />
</button>
)}
</div>
{/* Divider */}
<span className="w-px h-6 bg-zinc-800 mx-1" />
{/* Global offset slider — GPU only */}
{!readOnly && uniformDeltaMhz !== null && (
<div className="flex items-center gap-1.5 min-w-[260px]">
<span className="text-zinc-500 text-xs whitespace-nowrap">Global Offset</span>
<input
type="range"
min={-1000}
max={1000}
step={5}
value={offsetMhz}
onChange={(e) => handleOffsetChange(Number(e.target.value))}
className="w-32 accent-cyan-400"
title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`}
/>
<span
className={[
'text-xs font-mono w-16',
offsetMhz > 0 ? 'text-cyan-400' : offsetMhz < 0 ? 'text-orange-400' : 'text-zinc-500',
].join(' ')}
>
{offsetMhz > 0 ? '+' : ''}{offsetMhz} MHz
</span>
</div>
)}
{/* Flatten — visible when 2+ points are selected */}
{!readOnly && selectedPoints.size >= 2 && (() => {
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorDelta =
pendingDeltas.get(anchor) ??
curve?.points.find(p => p.index === anchor)?.delta_khz ??
0;
const label = `·${anchor} ${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz`;
return (
<button
onClick={flattenToAnchor}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz)`}
>
<Minus size={11} />
Flatten to {label}
</button>
);
})()}
{/* Legend — right-aligned */}
<div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" /> effective</span>
<span className="flex items-center gap-1"><span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" /> pending</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-yellow-400" /> current</span>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { fmt } from '../../utils/units';
import type { VFPoint } from '../../types';
interface Props {
point: VFPoint;
/** Pending delta in kHz (from store), if any */
pendingDeltaKhz?: number;
/** True if this point is being held up by monotonicity enforcement */
isClamped?: boolean;
}
/**
* HTML tooltip rendered as an absolutely-positioned overlay on top of the
* SVG so it's never clipped by the SVG viewport.
*/
export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
const hasPending = pendingDeltaKhz !== undefined;
const pendingMhz = hasPending ? pendingDeltaKhz! / 1000 : 0;
const deltaChange = hasPending ? pendingDeltaKhz! - point.delta_khz : 0;
const pendingEffMhz = hasPending ? point.freq_mhz + deltaChange / 1000 : null;
return (
<div
style={{
position: 'absolute',
right: 16,
bottom: 16,
pointerEvents: 'none',
zIndex: 50,
width: 172,
}}
className="bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
>
<div className="text-zinc-400 mb-1">Point {point.index}</div>
<div className="text-zinc-200">
<span className="text-zinc-400">Volt: </span>{fmt.mv(point.volt_mv, 1)}
</div>
<div className="text-zinc-200">
<span className="text-zinc-400">Offset: </span>
<span className={point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-400'}>
{point.delta_khz > 0 ? '+' : ''}{fmt.mhz(point.delta_mhz, 1)}
</span>
</div>
<div className="text-emerald-300 font-semibold">
<span className="text-zinc-400">Eff.: </span>{fmt.mhz(point.freq_mhz, 0)}
{isClamped && <span className="text-amber-500 ml-1">⇡</span>}
</div>
{isClamped && (
<div className="text-amber-500/80 text-[10px] mt-0.5">
Clamped by lower-voltage point
</div>
)}
{hasPending && (
<>
<div className="border-t border-zinc-700 mt-1.5 pt-1.5">
<div className="text-zinc-200">
<span className="text-zinc-400">Pending: </span>
<span className={pendingMhz > 0 ? 'text-cyan-400' : pendingMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}>
{pendingMhz > 0 ? '+' : ''}{pendingMhz.toFixed(1)} MHz
</span>
</div>
<div className="text-cyan-300 font-semibold">
<span className="text-zinc-400">→ Eff.: </span>{fmt.mhz(pendingEffMhz, 0)}
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,239 @@
import { useState, useEffect } from 'react';
import { Check, X, RotateCcw } from 'lucide-react';
import { api } from '../../api/client';
import { useCurveStore } from '../../store/curveStore';
import type { LimitsState } from '../../types';
import { toast } from 'sonner';
import { ConfirmDialog } from '../common/ConfirmDialog';
type Pending = Pick<LimitsState, 'power_limit_w' | 'mem_offset_mhz'>;
export function PerformancePanel() {
const { selectedGpuIndex } = useCurveStore();
const [limits, setLimits] = useState<LimitsState | null>(null);
const [pending, setPending] = useState<Partial<Pending>>({});
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmApply, setConfirmApply] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
async function fetchLimits() {
try {
setLoading(true);
setLimits(await api.limits(selectedGpuIndex));
} catch {
toast.error('Failed to load performance limits');
} finally {
setLoading(false);
}
}
useEffect(() => { fetchLimits(); }, [selectedGpuIndex]);
async function handleApply() {
setBusy(true);
setError(null);
try {
await api.updateLimits(pending, selectedGpuIndex);
useCurveStore.getState().setActiveProfile(null);
setPending({});
setConfirmApply(false);
await fetchLimits();
toast.success('Performance limits applied');
} catch (e: any) {
setError(e.message ?? String(e));
setConfirmApply(false);
} finally {
setBusy(false);
}
}
async function handleReset() {
setBusy(true);
setError(null);
try {
await api.resetLimits(selectedGpuIndex);
useCurveStore.getState().setActiveProfile(null);
setPending({});
setConfirmReset(false);
await fetchLimits();
toast.success('Performance limits reset to defaults');
} catch (e: any) {
setError(e.message ?? String(e));
setConfirmReset(false);
} finally {
setBusy(false);
}
}
if (loading && !limits) {
return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col animate-pulse">
<div className="px-3 py-2 border-b border-zinc-800 h-9" />
<div className="p-4 flex flex-col gap-6">
<div className="h-12 bg-zinc-800 rounded" />
<div className="h-12 bg-zinc-800 rounded" />
</div>
</div>
);
}
if (!limits) return null;
const hasPending = Object.keys(pending).length > 0;
// Power
const pwrVal = pending.power_limit_w ?? limits.power_limit_w ?? 0;
const pwrMin = limits.min_power_limit_w ?? 100;
const pwrMax = limits.max_power_limit_w ?? 600;
// Memory offset
const memVal = pending.mem_offset_mhz ?? limits.mem_offset_mhz ?? 0;
const memMin = limits.min_mem_offset_mhz ?? -2000;
const memMax = Math.min(limits.max_mem_offset_mhz ?? 3000, 3000);
return (
<>
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Performance</span>
{hasPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
{Object.keys(pending).length} pending
</span>
)}
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setConfirmApply(true)}
disabled={!hasPending || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Check size={12} />
Apply
</button>
<button
onClick={() => { setPending({}); setError(null); }}
disabled={!hasPending || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<X size={12} />
Discard
</button>
<button
onClick={() => setConfirmReset(true)}
disabled={busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-red-900 text-zinc-500 hover:text-red-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<RotateCcw size={11} />
Reset
</button>
</div>
</div>
{/* ── Error banner ────────────────────────────────────────────────── */}
{error && (
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
<span>⚠ {error}</span>
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">✕</button>
</div>
)}
<div className="flex flex-col divide-y divide-zinc-800">
{/* ── Board Power Limit ─────────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Board Power Limit</span>
<div className="flex items-center gap-1.5">
<input
type="number"
min={pwrMin}
max={pwrMax}
value={pwrVal}
onChange={e => {
const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, power_limit_w: v }));
}}
className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/>
<span className="text-xs text-zinc-600 w-3">W</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-8 text-right">{pwrMin}</span>
<input
type="range"
min={pwrMin}
max={pwrMax}
value={pwrVal}
onChange={e => setPending(p => ({ ...p, power_limit_w: parseInt(e.target.value) }))}
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/>
<span className="text-xs text-zinc-600 font-mono w-8">{pwrMax}</span>
</div>
</div>
{/* ── Memory Clock Offset ───────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Memory Clock Offset</span>
<div className="flex items-center gap-1.5">
<input
type="number"
min={memMin}
max={memMax}
value={memVal}
onChange={e => {
const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, mem_offset_mhz: v }));
}}
className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/>
<span className="text-xs text-zinc-600 w-6">MHz</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-10 text-right">{memMin}</span>
<input
type="range"
min={memMin}
max={memMax}
step={1}
value={memVal}
onChange={e => setPending(p => ({ ...p, mem_offset_mhz: parseInt(e.target.value) }))}
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/>
<span className="text-xs text-zinc-600 font-mono w-10">+{memMax}</span>
</div>
</div>
</div>
</div>
{confirmApply && (
<ConfirmDialog
message="Apply performance limits to hardware?"
detail="Power limit and memory clock offset take effect immediately."
confirmLabel="Apply"
onConfirm={handleApply}
onCancel={() => setConfirmApply(false)}
/>
)}
{confirmReset && (
<ConfirmDialog
message="Reset performance limits to defaults?"
detail="Power limit will be restored to the hardware default and memory clock offset set to 0."
confirmLabel="Reset"
isDestructive
onConfirm={handleReset}
onCancel={() => setConfirmReset(false)}
/>
)}
</>
);
}
@@ -0,0 +1,52 @@
interface Props {
label: string;
value: string;
history: number[];
unit?: string;
color?: string; // tailwind color class for the sparkline stroke
max?: number;
}
const SPARKLINE_H = 32;
const SPARKLINE_W = 120;
function Sparkline({ data, color = '#a78bfa', max }: { data: number[]; color?: string; max?: number }) {
const points = data.length >= 2 ? (() => {
const dataMax = max ?? Math.max(...data, 1);
const dataMin = Math.min(...data, 0);
const range = dataMax - dataMin || 1;
return data.map((v, i) => {
const x = (i / (data.length - 1)) * SPARKLINE_W;
const y = SPARKLINE_H - ((v - dataMin) / range) * SPARKLINE_H;
return `${x},${y}`;
}).join(' ');
})() : null;
return (
<svg width="100%" height={SPARKLINE_H} viewBox={`0 0 ${SPARKLINE_W} ${SPARKLINE_H}`} preserveAspectRatio="none" className="overflow-visible">
{points && (
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
opacity="0.8"
/>
)}
</svg>
);
}
export function GaugeCard({ label, value, history, color = '#a78bfa', max }: Props) {
return (
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-1 min-w-0">
<div className="text-xs text-zinc-500 uppercase tracking-wider">{label}</div>
<div className="text-xl font-mono font-semibold text-zinc-100">{value}</div>
<div className="mt-1 w-full">
<Sparkline data={history} color={color} max={max} />
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample } from '../../types';
interface Props {
monitor: MonitoringSample | null;
history: MonitoringSample[];
}
function pluck<K extends keyof MonitoringSample>(
history: MonitoringSample[],
key: K,
): number[] {
return history.map((s) => (s[key] as number | null) ?? 0);
}
export function LiveMonitor({ monitor, history }: Props) {
return (
<div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
</div>
<div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Core Clock"
value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, 'clock_mhz')}
color="#34d399"
max={3000}
/>
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, 'voltage_mv')}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, 'gpu_util_pct')}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard
label="P-State"
value={monitor?.pstate_label ?? 'Unknown'}
history={pluck(history, 'pstate')}
color="#a8a29e"
max={15}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample } from '../../types';
interface Props {
monitor: MonitoringSample | null;
history: MonitoringSample[];
}
function pluck<K extends keyof MonitoringSample>(
history: MonitoringSample[],
key: K,
): number[] {
return history.map((s) => (s[key] as number | null) ?? 0);
}
export function PerformanceMonitor({ monitor, history }: Props) {
const memUsed = monitor?.mem_used_mib ?? null;
const memTotal = monitor?.mem_total_mib ?? null;
const memLabel = memUsed != null && memTotal != null
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: '—';
return (
<div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
</div>
<div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Mem Clock"
value={fmt.mhz(monitor?.mem_clock_mhz)}
history={pluck(history, 'mem_clock_mhz')}
color="#67e8f9"
max={20000}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
color="#f472b6"
max={600}
/>
<GaugeCard
label="VRAM Used"
value={memLabel}
history={pluck(history, 'mem_used_mib')}
color="#a78bfa"
max={memTotal ?? 32768}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,128 @@
import { Cpu, Wifi, WifiOff, Loader, Terminal, ChevronDown } from 'lucide-react';
import type { GpuInfo, MonitoringSample } from '../../types';
import { fmt } from '../../utils/units';
import { useCurveStore } from '../../store/curveStore';
import { useState, useRef, useEffect } from 'react';
interface Props {
gpuInfo: GpuInfo | null;
wsStatus: 'connecting' | 'connected' | 'disconnected';
monitor: MonitoringSample | null;
}
const statusIcon = {
connected: <Wifi size={14} className="text-emerald-400" />,
connecting: <Loader size={14} className="text-yellow-400 animate-spin" />,
disconnected: <WifiOff size={14} className="text-red-400" />,
};
const statusText = {
connected: 'Connected',
connecting: 'Connecting…',
disconnected: 'Disconnected',
};
export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } = useCurveStore();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<header className="bg-zinc-900 border-b border-zinc-800 text-sm">
<div className="flex items-center gap-4 px-4 py-2 w-full max-w-[1600px] mx-auto min-w-0">
{/* Left: GPU name */}
<div className="flex items-center gap-2 min-w-0 shrink-0">
<img src="/logo.svg" alt="NVCurve" className="w-5 h-5 shrink-0" />
<span className="font-bold text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-fuchsia-400 tracking-tight mr-1">
NVCurve
</span>
<div className="w-px h-4 bg-zinc-700 mx-1 hidden sm:block"></div>
{availableGpus.length > 1 ? (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1.5 px-2 py-1 -ml-2 rounded hover:bg-zinc-800 transition-colors group"
>
<Cpu size={14} className="text-zinc-400 group-hover:text-zinc-300 transition-colors" />
<span className="font-medium text-zinc-200 truncate group-hover:text-white transition-colors">
{gpuInfo?.name ?? 'No Device'}
</span>
<ChevronDown size={14} className={`text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-1 w-64 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl overflow-hidden z-50 py-1">
{availableGpus.map((gpu) => (
<button
key={gpu.index}
onClick={() => {
setSelectedGpuIndex(gpu.index);
setIsOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-zinc-700 transition-colors ${
gpu.index === selectedGpuIndex ? 'text-cyan-400 font-medium bg-zinc-700/50' : 'text-zinc-300'
}`}
>
<Cpu size={14} className={gpu.index === selectedGpuIndex ? 'text-cyan-400' : 'text-zinc-500'} />
<span className="truncate flex-1">{gpu.name}</span>
<span className="text-xs text-zinc-500 font-mono">GPU {gpu.index}</span>
</button>
))}
</div>
)}
</div>
) : (
<>
<Cpu size={16} className="shrink-0 text-zinc-400 ml-1 hidden sm:block" />
<span className="font-medium text-zinc-200 truncate">
{gpuInfo?.name ?? 'No Device'}
</span>
</>
)}
{gpuInfo?.driver_version && (
<div className="flex items-center gap-1.5 text-zinc-400 shrink-0 hidden sm:flex bg-zinc-800/80 border border-zinc-700/50 px-2 py-0.5 rounded text-xs ml-2">
<Terminal size={12} className="text-zinc-500" />
<span>{gpuInfo.driver_version}</span>
</div>
)}
</div>
{/* Centre: live stats */}
<div className="flex-1 flex items-center justify-center gap-5 min-w-0 hidden lg:flex">
<StatPill label="Temp" value={fmt.celsius(monitor?.temp_c)} color="text-orange-400" />
<StatPill label="Power" value={fmt.watts(monitor?.power_w)} color="text-pink-400" />
<StatPill label="Fan" value={fmt.pct(monitor?.fan_pct)} color="text-blue-400" />
<StatPill label="GPU Util" value={fmt.pct(monitor?.gpu_util_pct)} color="text-yellow-400" />
<StatPill label="Mem Util" value={fmt.pct(monitor?.mem_util_pct)} color="text-sky-400" />
</div>
{/* Right: connection status */}
<div className="flex items-center gap-1.5 text-zinc-400 shrink-0">
{statusIcon[wsStatus]}
<span className="hidden sm:inline">{statusText[wsStatus]}</span>
</div>
</div>
</header>
);
}
function StatPill({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="flex items-baseline gap-1">
<span className="text-zinc-500 text-xs">{label}</span>
<span className={`font-mono font-semibold text-sm ${color}`}>{value}</span>
</div>
);
}
@@ -0,0 +1,124 @@
import { useState, useRef, useEffect } from 'react';
import { fmt } from '../../utils/units';
import type { VFPoint } from '../../types';
import { useCurveStore } from '../../store/curveStore';
interface Props {
point: VFPoint;
isCurrent: boolean;
isSelected: boolean;
isClamped: boolean;
pendingDeltaKhz?: number;
shouldAutoScroll?: boolean;
onMouseDown?: (e: React.MouseEvent) => void;
onMouseEnter?: () => void;
}
export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDeltaKhz, shouldAutoScroll, onMouseDown, onMouseEnter }: Props) {
const { stageEdit } = useCurveStore();
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const trRef = useRef<HTMLTableRowElement>(null);
useEffect(() => {
if (shouldAutoScroll && trRef.current) {
// @ts-expect-error: the typing seems to not include the valid 'container' option
trRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', container: 'nearest' });
}
}, [shouldAutoScroll]);
const hasPending = pendingDeltaKhz !== undefined;
const displayDeltaMhz = hasPending ? pendingDeltaKhz / 1000 : point.delta_mhz;
const displayDeltaKhz = hasPending ? pendingDeltaKhz : point.delta_khz;
// Effective = current effective + delta change
const deltaChange = hasPending ? pendingDeltaKhz - point.delta_khz : 0;
const displayEffMhz = point.freq_mhz + deltaChange / 1000;
const deltaColor = hasPending
? displayDeltaKhz > 0 ? 'text-cyan-400' : displayDeltaKhz < 0 ? 'text-orange-400' : 'text-zinc-400'
: point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-500';
function startEdit() {
setInputValue((displayDeltaMhz).toFixed(1));
setEditing(true);
setTimeout(() => {
inputRef.current?.select();
}, 0);
}
function commitEdit() {
const mhz = parseFloat(inputValue);
if (!isNaN(mhz) && isFinite(mhz)) {
const clamped = Math.max(-1000, Math.min(1000, mhz));
stageEdit(point.index, Math.round(clamped * 1000));
}
setEditing(false);
}
function cancelEdit() {
setEditing(false);
}
return (
<tr
ref={trRef}
className={[
'border-b border-zinc-800 text-xs font-mono cursor-pointer',
isCurrent ? 'bg-yellow-400/10' : isSelected ? 'bg-cyan-500/10' : 'hover:bg-zinc-800/50',
].join(' ')}
onMouseDown={(e) => {
if (editing) return;
onMouseDown?.(e);
}}
onMouseEnter={() => {
if (editing) return;
onMouseEnter?.();
}}
>
<td className="px-3 py-1 text-zinc-500">{point.index}</td>
<td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td>
{/* Offset — click to edit inline */}
<td className={`px-3 py-1 ${deltaColor}`} onClick={(e) => { e.stopPropagation(); startEdit(); }}>
{editing ? (
<input
ref={inputRef}
type="number"
step="1"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); }
if (e.key === 'Escape') cancelEdit();
}}
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs"
style={{ fontFamily: 'monospace' }}
/>
) : (
<span title="Click to edit">
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
{displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz
</span>
)}
</td>
{/* Eff. Freq */}
<td className={`px-3 py-1 font-semibold ${hasPending ? 'text-cyan-200' : 'text-zinc-100'}`}>
{fmt.mhz(displayEffMhz, 0)}
{isClamped && !hasPending && (
<span
className="ml-1 text-amber-500 cursor-help"
title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up"
>⇡</span>
)}
</td>
<td className="px-3 py-1">
{isCurrent && <span className="text-yellow-400">◀ now</span>}
{isSelected && !isCurrent && <span className="text-cyan-500">●</span>}
</td>
</tr>
);
}
@@ -0,0 +1,119 @@
import { useState, useMemo, useEffect } from 'react';
import { PointRow } from './PointRow';
import type { VFPoint } from '../../types';
import { findCurrentPoint, detectClampedPoints } from '../../utils/curveHelpers';
import { useCurveStore } from '../../store/curveStore';
interface Props {
points: VFPoint[];
currentVoltageMv: number | null;
readOnly?: boolean;
}
export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
const { pendingDeltas, selectedPoints, selectPoint, selectRange } = useCurveStore();
const currentPoint = findCurrentPoint(points, currentVoltageMv);
const clampedPoints = useMemo(() => detectClampedPoints(points), [points]);
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
useEffect(() => {
function onUp() { setDragStartIdx(null); }
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
}, []);
return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">Points</span>
{!readOnly && pendingDeltas.size > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
{pendingDeltas.size} staged
</span>
)}
{readOnly && (
<span className="text-xs text-zinc-600 italic">read-only</span>
)}
<span className="ml-auto text-xs text-zinc-600">{points.length} points</span>
{!readOnly && selectedPoints.size === 1 && (
<div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4">
<button
onClick={() => {
const idx = Array.from(selectedPoints)[0];
const beforeIdxs = points.filter(p => p.index <= idx).map(p => p.index);
selectRange(beforeIdxs);
}}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
>
Select Before
</button>
<button
onClick={() => {
const idx = Array.from(selectedPoints)[0];
const afterIdxs = points.filter(p => p.index >= idx).map(p => p.index);
selectRange(afterIdxs);
}}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
>
Select After
</button>
</div>
)}
</div>
<div className="overflow-auto max-h-[32rem] select-none">
<table className="w-full">
<thead className="sticky top-0 bg-zinc-900 z-10">
<tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800">
<th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Voltage</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Offset</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Eff. Freq</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900" />
</tr>
</thead>
<tbody>
{points.map((p) => (
<PointRow
key={p.index}
point={p}
isCurrent={currentPoint?.index === p.index}
isSelected={selectedPoints.has(p.index)}
isClamped={clampedPoints.has(p.index)}
pendingDeltaKhz={pendingDeltas.get(p.index)}
shouldAutoScroll={selectedPoints.size === 1 && selectedPoints.has(p.index)}
onMouseDown={readOnly ? undefined : (e) => {
if (e.shiftKey) {
const currentSelected = Array.from(selectedPoints);
if (currentSelected.length > 0) {
const last = Math.max(...currentSelected);
const min = Math.min(last, p.index);
const max = Math.max(last, p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
selectRange(toSelect);
} else {
selectPoint(p.index, false);
}
} else if (e.ctrlKey || e.metaKey) {
selectPoint(p.index, true);
} else {
setDragStartIdx(p.index);
selectPoint(p.index, false);
}
}}
onMouseEnter={readOnly ? undefined : () => {
if (dragStartIdx !== null) {
const min = Math.min(dragStartIdx, p.index);
const max = Math.max(dragStartIdx, p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
selectRange(toSelect);
}
}}
/>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,337 @@
import { useState, useEffect, useRef } from 'react';
import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react';
import { api } from '../../api/client';
import type { ProfileData } from '../../types';
import { toast } from 'sonner';
import { useCurveStore } from '../../store/curveStore';
interface ProfilePanelProps {
activeProfile: string | null;
onProfileApplied: (name: string | null) => void;
}
export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) {
const { selectedGpuIndex, gpuInfo } = useCurveStore();
const [profiles, setProfiles] = useState<ProfileData[]>([]);
const [loading, setLoading] = useState(true);
const [autoLoadProfile, setAutoLoadProfile] = useState<string | null>(null);
// Save form
const [isSaveOpen, setIsSaveOpen] = useState(false);
const [newName, setNewName] = useState('');
const [isSaving, setIsSaving] = useState(false);
const saveInputRef = useRef<HTMLInputElement>(null);
// Inline delete confirmation
const [deletingName, setDeletingName] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Inline rename
const [renamingName, setRenamingName] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
// Apply in-flight
const [applyingName, setApplyingName] = useState<string | null>(null);
async function fetchProfiles() {
try {
const data = await api.profiles(selectedGpuIndex);
setProfiles(data.profiles);
onProfileApplied(data.active);
setAutoLoadProfile(data.auto_load);
} catch {
toast.error('Failed to load profiles');
} finally {
setLoading(false);
}
}
async function handleSetAutoLoad(name: string | null) {
try {
await api.setAutoLoadProfile(name, selectedGpuIndex);
setAutoLoadProfile(name);
if (name) toast.success(`"${name}" will load on server start`);
else toast.success('Auto-load cleared');
} catch (e: any) {
toast.error('Failed to update default profile: ' + (e.message || String(e)));
}
}
useEffect(() => { fetchProfiles(); }, [selectedGpuIndex]);
useEffect(() => {
if (isSaveOpen) saveInputRef.current?.focus();
else setNewName('');
}, [isSaveOpen]);
useEffect(() => {
if (renamingName) renameInputRef.current?.focus();
}, [renamingName]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
setIsSaving(true);
await api.saveProfile(newName.trim(), selectedGpuIndex);
toast.success(`Profile "${newName.trim()}" saved`);
setIsSaveOpen(false);
await fetchProfiles();
} catch (e: any) {
toast.error('Failed to save: ' + (e.message || String(e)));
} finally {
setIsSaving(false);
}
}
async function handleApply(name: string) {
try {
setApplyingName(name);
await api.applyProfile(name, selectedGpuIndex);
onProfileApplied(name);
toast.success(`"${name}" applied`);
} catch (e: any) {
toast.error(`Failed to apply "${name}": ` + (e.message || String(e)));
} finally {
setApplyingName(null);
}
}
async function handleDeleteConfirm(name: string) {
try {
setIsDeleting(true);
await api.deleteProfile(name);
toast.success(`"${name}" deleted`);
if (activeProfile === name) onProfileApplied(null);
if (autoLoadProfile === name) setAutoLoadProfile(null);
setDeletingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error('Failed to delete: ' + (e.message || String(e)));
} finally {
setIsDeleting(false);
}
}
async function handleRename(e: React.FormEvent, oldName: string) {
e.preventDefault();
if (!renameValue.trim() || renameValue.trim() === oldName) {
setRenamingName(null);
return;
}
try {
setIsRenaming(true);
await api.renameProfile(oldName, renameValue.trim());
toast.success(`Renamed to "${renameValue.trim()}"`);
if (activeProfile === oldName) onProfileApplied(renameValue.trim());
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
setRenamingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error('Failed to rename: ' + (e.message || String(e)));
} finally {
setIsRenaming(false);
}
}
if (loading && profiles.length === 0) {
return (
<div className="bg-zinc-900 rounded-lg p-6 flex flex-col gap-4 animate-pulse">
<div className="h-6 w-32 bg-zinc-800 rounded" />
<div className="h-20 w-full bg-zinc-800 rounded" />
</div>
);
}
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-lg overflow-hidden flex flex-col">
{/* Header */}
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-950/50 flex items-center gap-2">
<Save size={16} className="text-pink-500" />
<h2 className="text-zinc-100 font-medium">Profiles</h2>
</div>
{/* Save section */}
<div className="px-4 py-3 border-b border-zinc-800">
{isSaveOpen ? (
<form onSubmit={handleSave} className="flex gap-2">
<input
ref={saveInputRef}
type="text"
placeholder="Profile name..."
value={newName}
onChange={e => setNewName(e.target.value)}
disabled={isSaving}
onKeyDown={e => e.key === 'Escape' && setIsSaveOpen(false)}
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
/>
<button
type="submit"
disabled={!newName.trim() || isSaving}
className="px-3 py-1.5 bg-pink-600 hover:bg-pink-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded text-sm font-medium transition shrink-0"
>
Save
</button>
<button
type="button"
onClick={() => setIsSaveOpen(false)}
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
>
Cancel
</button>
</form>
) : (
<button
onClick={() => setIsSaveOpen(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded text-sm font-medium text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition border border-zinc-800 hover:border-zinc-700"
>
<Save size={14} />
Save Current State
</button>
)}
</div>
{/* Profile list */}
<div className="overflow-y-auto max-h-72 py-2 scrollbar-thin scrollbar-thumb-zinc-700 scrollbar-track-transparent">
{profiles.length === 0 ? (
<p className="text-center text-zinc-500 text-sm py-8 px-4">
No profiles saved yet.
</p>
) : (
<div className="flex flex-col gap-0.5 px-2">
{profiles.map(p => {
const pts = Object.keys(p.curve_deltas).length;
const badges = [
pts > 0 ? `${pts} pts` : null,
p.power_limit_w != null ? `${p.power_limit_w}W` : null,
p.mem_offset_mhz != null ? `${p.mem_offset_mhz > 0 ? '+' : ''}${p.mem_offset_mhz} MHz mem` : null,
].filter(Boolean).join(' · ');
const isActive = activeProfile === p.name;
const isAutoLoad = autoLoadProfile === p.name;
const isApplying = applyingName === p.name;
const isConfirmingDelete = deletingName === p.name;
const isRenaming_ = renamingName === p.name;
return (
<div
key={p.name}
className={`group rounded-md transition ${isActive ? 'bg-zinc-800/60' : 'hover:bg-zinc-800/50'}`}
>
{isConfirmingDelete ? (
<div className="flex items-center justify-between px-3 py-2 gap-2">
<span className="text-sm text-zinc-300 truncate min-w-0">Delete "{p.name}"?</span>
<div className="flex gap-1.5 shrink-0">
<button
onClick={() => handleDeleteConfirm(p.name)}
disabled={isDeleting}
className="px-2 py-1 text-xs rounded bg-red-600 hover:bg-red-500 text-white font-medium transition disabled:opacity-50"
>
Delete
</button>
<button
onClick={() => setDeletingName(null)}
className="px-2 py-1 text-xs rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 transition"
>
Cancel
</button>
</div>
</div>
) : isRenaming_ ? (
<form onSubmit={e => handleRename(e, p.name)} className="flex items-center gap-2 px-3 py-2">
<input
ref={renameInputRef}
type="text"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
disabled={isRenaming}
onKeyDown={e => e.key === 'Escape' && setRenamingName(null)}
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
/>
<button
type="submit"
disabled={!renameValue.trim() || isRenaming}
className="px-2 py-1 text-xs rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-200 font-medium transition disabled:opacity-50 shrink-0"
>
OK
</button>
<button
type="button"
onClick={() => setRenamingName(null)}
className="px-2 py-1 text-xs rounded text-zinc-500 hover:text-zinc-300 transition shrink-0"
>
Cancel
</button>
</form>
) : (
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 min-w-0 pr-2">
{isActive
? <Check size={13} className="text-emerald-400 shrink-0" />
: <span className="w-[13px] shrink-0" />
}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className={`text-sm font-medium truncate ${isActive ? 'text-zinc-100' : 'text-zinc-300'}`}>
{p.name}
</p>
{isAutoLoad && (
<Star size={11} className="text-sky-400 shrink-0" fill="currentColor" />
)}
{gpuInfo && p.gpu_name !== gpuInfo.name && (
<span className="text-xs text-zinc-600 truncate shrink-0" title={`Saved from ${p.gpu_name}`}>
{p.gpu_name}
</span>
)}
</div>
{badges && <p className="text-xs text-zinc-500">{badges}</p>}
</div>
</div>
<div className="flex gap-1 shrink-0 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleApply(p.name)}
disabled={isApplying}
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium"
>
{isApplying
? <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
: <ChevronRight size={13} />
}
Apply
</button>
<button
onClick={() => handleSetAutoLoad(isAutoLoad ? null : p.name)}
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? 'text-sky-400 hover:text-sky-300' : 'text-zinc-600 hover:text-sky-400'}`}
title={isAutoLoad ? 'Clear default profile' : 'Set as default profile'}
>
<Star size={13} fill={isAutoLoad ? 'currentColor' : 'none'} />
</button>
<button
onClick={() => { setRenamingName(p.name); setRenameValue(p.name); }}
className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition"
title="Rename"
>
<Pencil size={13} />
</button>
<button
onClick={() => setDeletingName(p.name)}
className="p-1.5 text-zinc-600 hover:text-red-400 hover:bg-zinc-700 rounded transition"
title="Delete"
>
<Trash2 size={13} />
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
interface Props {
message: string;
detail?: string;
confirmLabel?: string;
isDestructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
message,
detail,
confirmLabel = 'Confirm',
isDestructive = false,
onConfirm,
onCancel,
}: Props) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.7)' }}
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]">
<h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2>
{detail && <p className="text-zinc-400 text-xs mb-4">{detail}</p>}
{!detail && <div className="mb-4" />}
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="px-3 py-1.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className={[
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
isDestructive
? 'bg-red-600 hover:bg-red-500 text-white'
: 'bg-emerald-600 hover:bg-emerald-500 text-white',
].join(' ')}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { createWsConnection } from '../api/websocket';
import { useCurveStore } from '../store/curveStore';
import type { CurveState } from '../types';
export function useCurve() {
const { curve, setCurve, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => {
// Initial fetch
api.curve(selectedGpuIndex).then(setCurve).catch(console.error);
// Subscribe to /ws/curve for push updates after writes
wsRef.current = createWsConnection<CurveState>(
'/ws/curve',
(data) => setCurve(data),
setWsStatus,
selectedGpuIndex,
);
return () => wsRef.current?.close();
}, [setCurve, selectedGpuIndex]);
return { curve, wsStatus };
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { api } from '../api/client';
import { useCurveStore } from '../store/curveStore';
export function useGpu() {
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore();
useEffect(() => {
api.gpus().then(setAvailableGpus).catch(console.error);
}, [setAvailableGpus]);
useEffect(() => {
api.gpu(selectedGpuIndex).then(setGpuInfo).catch(console.error);
}, [setGpuInfo, selectedGpuIndex]);
return gpuInfo;
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useRef, useState } from 'react';
import { createWsConnection } from '../api/websocket';
import { useCurveStore } from '../store/curveStore';
import type { MonitoringSample } from '../types';
export function useMonitor() {
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => {
wsRef.current = createWsConnection<MonitoringSample>(
'/ws/monitor',
pushMonitor,
setWsStatus,
selectedGpuIndex,
);
return () => wsRef.current?.close();
}, [pushMonitor, selectedGpuIndex]);
return { monitor, monitorHistory, wsStatus };
}
+42
View File
@@ -0,0 +1,42 @@
@import "tailwindcss";
*, *::before, *::after {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
body {
background-color: #09090b; /* zinc-950 */
color: #f4f4f5; /* zinc-100 */
font-family: system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Tailwind v4 dark mode via media (fallback) */
@media (prefers-color-scheme: light) {
body {
background-color: #09090b;
color: #f4f4f5;
}
}
/* scrollbar styling for dark theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #18181b;
}
::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+249
View File
@@ -0,0 +1,249 @@
import { create } from 'zustand';
import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types';
import { api } from '../api/client';
import { toast } from 'sonner';
const HISTORY_SIZE = 120; // ~60s at 2Hz
interface CurveStore {
// Hardware state (from API)
availableGpus: GpuInfo[];
selectedGpuIndex: number;
curve: CurveState | null;
gpuInfo: GpuInfo | null;
monitor: MonitoringSample | null;
monitorHistory: MonitoringSample[];
activeProfile: string | null;
// Phase 4 — edit state
/** point index → pending delta in kHz (overrides curve.points[i].delta_khz for display) */
pendingDeltas: Map<number, number>;
selectedPoints: Set<number>;
/**
* The anchor point for multi-point operations (e.g. flatten).
* Set to the last explicitly clicked point. Bulk selects (box, range, Ctrl+A)
* leave it unchanged; clearSelection resets it to null.
* If null or not in selectedPoints, operations fall back to the lowest selected index.
*/
anchorPoint: number | null;
// Hardware state setters
setAvailableGpus: (gpus: GpuInfo[]) => void;
setSelectedGpuIndex: (index: number) => void;
setCurve: (c: CurveState) => void;
setGpuInfo: (g: GpuInfo) => void;
pushMonitor: (s: MonitoringSample) => void;
setActiveProfile: (name: string | null) => void;
// Edit actions
stageEdit: (pointIndex: number, deltaKhz: number) => void;
stageMultiEdit: (edits: Map<number, number>) => void;
stageRangeEdit: (points: VFPoint[], deltaKhz: number) => void;
discardEdits: () => void;
/**
* POST pending deltas to the backend, then call onSuccess (which re-fetches
* the curve) and clear the pending state.
*/
applyEdits: (onSuccess: () => void) => Promise<void>;
/** POST /api/curve/reset, call onSuccess, clear pending state. */
resetAllDeltas: (onSuccess: () => void) => Promise<void>;
// Selection actions
selectPoint: (index: number, multi?: boolean) => void;
selectRange: (indices: number[]) => void;
clearSelection: () => void;
/**
* Stage all selected points to the anchor point's current effective delta.
* Falls back to the lowest selected index if anchorPoint is null or deselected.
* No-op if fewer than 2 points are selected.
*/
flattenToAnchor: () => void;
// Derived helper — effective MHz for a point including any pending delta
effectiveMhz: (point: VFPoint) => number;
// True if any pending delta would produce a negative effective frequency
hasNegativeFreqWarning: () => boolean;
}
export const useCurveStore = create<CurveStore>()((set, get) => ({
availableGpus: [],
selectedGpuIndex: 0,
curve: null,
gpuInfo: null,
monitor: null,
monitorHistory: [],
activeProfile: null,
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
setAvailableGpus: (availableGpus) => set({ availableGpus }),
setSelectedGpuIndex: (selectedGpuIndex) => {
set({ selectedGpuIndex, curve: null, gpuInfo: null, monitor: null, monitorHistory: [], pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null });
},
setCurve: (curve) => set({ curve }),
setGpuInfo: (gpuInfo) => set({ gpuInfo }),
setActiveProfile: (activeProfile) => set({ activeProfile }),
pushMonitor: (sample) =>
set((s) => {
const history = [...s.monitorHistory, sample];
if (history.length > HISTORY_SIZE) history.shift();
return { monitor: sample, monitorHistory: history };
}),
stageEdit: (pointIndex, deltaKhz) =>
set((s) => {
const next = new Map(s.pendingDeltas);
const point = s.curve?.points.find(p => p.index === pointIndex);
if (point && point.delta_khz === deltaKhz) {
next.delete(pointIndex);
} else {
next.set(pointIndex, deltaKhz);
}
return { pendingDeltas: next };
}),
stageMultiEdit: (edits) =>
set((s) => {
const next = new Map(s.pendingDeltas);
edits.forEach((deltaKhz, index) => {
const point = s.curve?.points.find(p => p.index === index);
if (point && point.delta_khz === deltaKhz) {
next.delete(index);
} else {
next.set(index, deltaKhz);
}
});
return { pendingDeltas: next };
}),
stageRangeEdit: (points, deltaKhz) =>
set((s) => {
const next = new Map(s.pendingDeltas);
for (const p of points) {
next.set(p.index, deltaKhz);
}
return { pendingDeltas: next };
}),
discardEdits: () =>
set({ pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }),
applyEdits: async (onSuccess) => {
const { pendingDeltas, selectedGpuIndex } = get();
if (pendingDeltas.size === 0) return;
// Convert Map to plain record for the API
const deltas: Record<number, number> = {};
pendingDeltas.forEach((v, k) => { deltas[k] = v; });
try {
const result = await api.writeDeltas(deltas, selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
if (result?.freq_warnings?.length) {
toast.warning('Curve applied — driver clamped some points to 0 MHz (negative freq delta)');
} else {
toast.success('Curve applied successfully');
}
onSuccess();
} catch (e: any) {
toast.error('Failed to apply curve: ' + (e.message || String(e)));
}
},
resetAllDeltas: async (onSuccess) => {
const { selectedGpuIndex } = get();
try {
await api.resetCurve(selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
toast.success('Curve reset to hardware defaults');
onSuccess();
} catch (e: any) {
toast.error('Failed to reset curve: ' + (e.message || String(e)));
}
},
selectPoint: (index, multi = false) =>
set((s) => {
const next = new Set(s.selectedPoints);
let anchor = s.anchorPoint;
if (multi) {
if (next.has(index)) {
next.delete(index);
if (anchor === index) anchor = next.size > 0 ? [...next].at(-1)! : null;
} else {
next.add(index);
anchor = index; // last explicitly added point is the new anchor
}
} else {
if (next.size === 1 && next.has(index)) {
next.clear();
anchor = null;
} else {
next.clear();
next.add(index);
anchor = index;
}
}
return { selectedPoints: next, anchorPoint: anchor };
}),
selectRange: (indices) =>
// Bulk selects don't change the anchor — preserve it if still in the new selection.
set((s) => {
const next = new Set(indices);
const anchor = s.anchorPoint !== null && next.has(s.anchorPoint) ? s.anchorPoint : null;
return { selectedPoints: next, anchorPoint: anchor };
}),
clearSelection: () =>
set({ selectedPoints: new Set(), anchorPoint: null }),
flattenToAnchor: () => {
const { selectedPoints, anchorPoint, pendingDeltas, curve, stageMultiEdit } = get();
if (selectedPoints.size < 2 || !curve) return;
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorPt = curve.points.find(p => p.index === anchor);
if (!anchorPt) return;
const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz;
const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta;
const edits = new Map<number, number>();
for (const idx of selectedPoints) {
const pt = curve.points.find(p => p.index === idx);
if (!pt) continue;
edits.set(idx, anchorEffectiveKhz - pt.freq_khz);
}
stageMultiEdit(edits);
},
effectiveMhz: (point) => {
const { pendingDeltas } = get();
if (pendingDeltas.has(point.index)) {
// freq_mhz is the current effective (VFP freq, already includes applied delta).
// Adjust by the change in delta: (pending - current_delta).
const pendingKhz = pendingDeltas.get(point.index)!;
const deltaChange = pendingKhz - point.delta_khz;
return point.freq_mhz + deltaChange / 1000;
}
return point.freq_mhz;
},
hasNegativeFreqWarning: () => {
const { pendingDeltas, curve } = get();
if (!curve || pendingDeltas.size === 0) return false;
for (const [index, pendingKhz] of pendingDeltas) {
const p = curve.points.find((pt) => pt.index === index);
if (!p || p.freq_khz === 0) continue;
// freq_khz is current effective; true base = freq_khz - delta_khz
// new effective = true_base + pendingKhz = freq_khz + (pendingKhz - delta_khz)
if (p.freq_khz + pendingKhz - p.delta_khz < 0) return true;
}
return false;
},
}));
+75
View File
@@ -0,0 +1,75 @@
export interface VFPoint {
index: number;
freq_khz: number;
freq_mhz: number;
volt_uv: number;
volt_mv: number;
delta_khz: number;
delta_mhz: number;
effective_freq_khz: number;
effective_freq_mhz: number;
domain: 'gpu' | 'memory';
}
export interface CurveState {
gpu_name: string;
timestamp: number;
points: VFPoint[];
}
export interface MonitoringSample {
timestamp: number;
voltage_uv: number | null;
voltage_mv: number | null;
clock_mhz: number | null;
temp_c: number | null;
power_w: number | null;
fan_pct: number | null;
pstate: number | null;
pstate_label: string | null;
mem_used_bytes: number | null;
mem_total_bytes: number | null;
mem_used_mib: number | null;
mem_total_mib: number | null;
gpu_util_pct: number | null;
mem_util_pct: number | null;
mem_clock_mhz: number | null;
}
export interface GpuInfo {
name: string;
index: number;
driver_version: string | null;
vram_bytes: number | null;
vram_gib: number | null;
}
export interface SnapshotInfo {
filepath: string;
timestamp: string;
gpu: string;
nonzero_offsets: number;
size: number;
}
export interface LimitsState {
// Power
power_limit_w: number | null;
default_power_limit_w: number | null;
min_power_limit_w: number | null;
max_power_limit_w: number | null;
// Clock offsets — current values
gpc_offset_mhz: number | null;
mem_offset_mhz: number | null;
// Memory offset — hardware-reported range
min_mem_offset_mhz: number | null;
max_mem_offset_mhz: number | null;
}
export interface ProfileData {
name: string;
gpu_name: string;
curve_deltas: Record<string, number>;
mem_offset_mhz: number | null;
power_limit_w: number | null;
}
+77
View File
@@ -0,0 +1,77 @@
import type { VFPoint } from '../types';
/**
* Approximate reference frequency (MHz) for a point: effective − delta.
*
* `p.freq_mhz` is the current effective frequency as reported by GetVFPCurve
* (already including any applied boost delta). Subtracting the delta gives an
* approximation of the hardware base.
*
* Note: NVIDIA enforces monotonicity across the V/F curve — a large delta on
* a lower-voltage point pushes up neighbouring points' effective frequencies
* even when those points have zero delta. So for monotonicity-affected points,
* `effective - delta` doesn't recover the true unmodified base. It is still
* useful as a faint reference line ("where this point would sit with no boost").
*/
export function refBaseMhz(p: VFPoint): number {
return p.freq_mhz - p.delta_mhz;
}
/** Find which VF point the GPU is currently near based on voltage reading */
export function findCurrentPoint(
points: VFPoint[],
voltage_mv: number | null,
): VFPoint | null {
if (voltage_mv == null || points.length === 0) return null;
return points.reduce((best, p) =>
Math.abs(p.volt_mv - voltage_mv) < Math.abs(best.volt_mv - voltage_mv) ? p : best,
);
}
/** Voltage domain extent, with padding */
export function voltExtent(points: VFPoint[], padMv = 20): [number, number] {
if (points.length === 0) return [600, 1100];
const min = Math.min(...points.map((p) => p.volt_mv));
const max = Math.max(...points.map((p) => p.volt_mv));
return [min - padMv, max + padMv];
}
/** Frequency domain extent for the effective (boosted) curve, with padding */
export function freqExtent(points: VFPoint[], padMhz = 50): [number, number] {
if (points.length === 0) return [1000, 3000];
const allFreqs = points.flatMap((p) => [p.freq_mhz, refBaseMhz(p)]);
const min = Math.min(...allFreqs);
const max = Math.max(...points.map((p) => p.freq_mhz));
return [min - padMhz, max + padMhz];
}
/**
* Detect points whose effective frequency is being held up by NVIDIA's
* monotonicity enforcement rather than their own offset.
*
* Walk points in voltage order, tracking the "ceiling" — the highest
* effective frequency seen so far and the offset that produced it. A point is
* clamped when:
* 1. Its effective freq is at or below the ceiling (hasn't moved past it)
* 2. Its own offset is lower than the offset that set the ceiling
*
* This catches cases like: point 103 has +950 MHz → effective 3907 MHz,
* point 104 has +315 MHz → effective also 3907 MHz (clamped).
*/
export function detectClampedPoints(points: VFPoint[]): Set<number> {
const clamped = new Set<number>();
let ceiling = -Infinity;
let ceilingOffset = -Infinity;
for (const p of points) {
if (p.freq_mhz <= ceiling && p.delta_khz < ceilingOffset) {
clamped.add(p.index);
}
if (p.freq_mhz > ceiling) {
ceiling = p.freq_mhz;
ceilingOffset = p.delta_khz;
}
}
return clamped;
}
+21
View File
@@ -0,0 +1,21 @@
export const fmt = {
mhz: (v: number | null | undefined, decimals = 0) =>
v == null ? '—' : `${v.toFixed(decimals)} MHz`,
mv: (v: number | null | undefined, decimals = 0) =>
v == null ? '—' : `${v.toFixed(decimals)} mV`,
celsius: (v: number | null | undefined, decimals = 0) =>
v == null ? '—' : `${v.toFixed(decimals)} °C`,
watts: (v: number | null | undefined, decimals = 0) =>
v == null ? '—' : `${v.toFixed(decimals)} W`,
pct: (v: number | null | undefined, decimals = 0) =>
v == null ? '—' : `${v.toFixed(decimals)}%`,
mib: (v: number | null | undefined) =>
v == null ? '—' : `${v.toFixed(0)} MiB`,
};
export const conv = {
khzToMhz: (khz: number) => khz / 1000,
uvToMv: (uv: number) => uv / 1000,
mvToUv: (mv: number) => mv * 1000,
mhzToKhz: (mhz: number) => mhz * 1000,
};
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:8042',
'/ws': { target: 'ws://localhost:8042', ws: true },
},
allowedHosts: ["localhost", ".localhost", ".ts.net"]
},
})
+3
View File
@@ -0,0 +1,3 @@
"""nvcurve — Linux GPU V/F curve editor for NVIDIA GPUs."""
__version__ = "0.5.0"
+10
View File
@@ -0,0 +1,10 @@
"""Allow running as: python -m nvcurve"""
import sys
if len(sys.argv) > 1 and sys.argv[1] == "daemon":
from .daemon import run
run()
else:
from .cli import main
main()
+1838
View File
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
"""HTTP client for communicating with a running nvcurve server."""
import httpx
from typing import Any
DEFAULT_BASE = "http://127.0.0.1:8042"
_TIMEOUT = 5.0
class ServerNotRunning(Exception):
"""Raised when the nvcurve server cannot be reached."""
class ApiError(Exception):
def __init__(self, status_code: int, detail: Any):
self.status_code = status_code
self.detail = detail
super().__init__(f"HTTP {status_code}: {detail}")
class NvCurveClient:
def __init__(self, base: str = DEFAULT_BASE, gpu_index: int = 0):
self._base = base.rstrip("/")
self.gpu_index = gpu_index
def _url(self, path: str) -> str:
sep = "&" if "?" in path else "?"
return f"{self._base}{path}{sep}gpu_index={self.gpu_index}"
def _raise(self, r: httpx.Response) -> None:
if r.is_error:
try:
detail = r.json().get("detail", r.text)
except Exception:
detail = r.text
raise ApiError(r.status_code, detail)
def _get(self, path: str) -> Any:
try:
r = httpx.get(self._url(path), timeout=_TIMEOUT)
except httpx.ConnectError:
raise ServerNotRunning()
self._raise(r)
return r.json()
def _post(self, path: str, body: Any = None) -> Any:
try:
r = httpx.post(self._url(path), json=body, timeout=_TIMEOUT)
except httpx.ConnectError:
raise ServerNotRunning()
self._raise(r)
return r.json()
def _delete(self, path: str) -> Any:
try:
r = httpx.delete(self._url(path), timeout=_TIMEOUT)
except httpx.ConnectError:
raise ServerNotRunning()
self._raise(r)
return r.json()
def ping(self) -> bool:
try:
httpx.get(self._url("/api/gpu"), timeout=1.0)
return True
except Exception:
return False
def gpus(self) -> list:
return self._get("/api/gpus")
# ── GPU ──────────────────────────────────────────────────────────────────
def gpu(self) -> dict:
return self._get("/api/gpu")
# ── Curve ────────────────────────────────────────────────────────────────
def curve(self) -> dict:
"""Returns {gpu_name, timestamp, points: [{index, freq_khz, volt_uv, delta_khz, ...}]}"""
return self._get("/api/curve")
def voltage(self) -> int | None:
"""Returns current GPU voltage in µV, or None if unavailable."""
try:
return self._get("/api/voltage")["voltage_uv"]
except Exception:
return None
def write_curve(
self,
deltas: dict[int, int],
max_delta_khz: int | None = None,
) -> dict:
body: dict = {"deltas": deltas}
if max_delta_khz is not None:
body["max_delta_khz"] = max_delta_khz
return self._post("/api/curve/write", body)
def write_global(self, delta_khz: int, max_delta_khz: int | None = None) -> dict:
body: dict = {"delta_khz": delta_khz}
if max_delta_khz is not None:
body["max_delta_khz"] = max_delta_khz
return self._post("/api/curve/write/global", body)
def reset_curve(self) -> dict:
return self._post("/api/curve/reset")
def verify_write(self, deltas: dict[int, int]) -> dict:
return self._post("/api/curve/verify", {"deltas": deltas})
# ── Snapshots ────────────────────────────────────────────────────────────
def snapshot_save(self) -> dict:
return self._post("/api/snapshot/save")
def snapshot_restore(self, filepath: str | None = None) -> dict:
return self._post("/api/snapshot/restore", {"filepath": filepath})
def snapshots(self) -> list:
return self._get("/api/snapshots")
# ── Profiles ─────────────────────────────────────────────────────────────
def profiles(self) -> dict:
return self._get("/api/profiles")
def profile_save(self, name: str) -> dict:
return self._post("/api/profiles", {"name": name})
def profile_apply(self, name: str) -> dict:
return self._post(f"/api/profiles/{name}/apply")
def profile_delete(self, name: str) -> dict:
return self._delete(f"/api/profiles/{name}")
# ── Config ───────────────────────────────────────────────────────────────
def config_get(self) -> dict:
return self._get("/api/config")
def config_update(self, auto_load_profile: str | None, gpu_index: int = 0) -> dict:
return self._post("/api/config", {"auto_load_profile": auto_load_profile, "gpu_index": gpu_index})
# ── Server control ───────────────────────────────────────────────────────
def shutdown(self) -> dict:
return self._post("/api/shutdown")
+31
View File
@@ -0,0 +1,31 @@
"""User-configurable settings with sensible defaults."""
import os
from dataclasses import dataclass, field
@dataclass
class Config:
# Safety limits
max_delta_khz: int = 3000_000 # ±3000 MHz hard cap
auto_snapshot: bool = True # Save snapshot before every write
max_snapshots: int = 20 # Maximum snapshots to keep (0 = unlimited)
# Monitoring
poll_interval_s: float = 1.0 # WebSocket monitor poll rate
# API server
host: str = "127.0.0.1"
port: int = 8042
snapshot_dir: str = "/var/cache/nvcurve/snapshots"
profile_dir: str = "/etc/nvcurve/profiles"
# Per-GPU default profiles: applied automatically on server startup.
# Key = stable GPU identifier (UUID string, "pci:{bus_id}", or "idx:{n}" fallback).
# Value = profile name (str).
auto_load_profiles: dict[str, str] = field(default_factory=dict)
# Module-level default config instance.
default_config = Config()
+209
View File
@@ -0,0 +1,209 @@
"""Minimal daemon: apply auto-load profiles on boot, manage web server via Unix socket.
Run via: nvcurve daemon
Or via systemd: nvcurve service install
Protocol: newline-delimited JSON, one request → one response, connection closed.
Commands:
{"cmd": "ping"}
{"cmd": "serve_start", "host": "127.0.0.1", "port": 8042}
{"cmd": "serve_stop"}
{"cmd": "serve_status"}
Requires root.
"""
import asyncio
import json
import logging
import os
import signal
import subprocess
import sys
log = logging.getLogger("nvcurve.daemon")
SOCKET_PATH = "/run/nvcurve-daemon.sock"
_PERSISTENT_CONFIG_FILE = "/etc/nvcurve/config.json"
# Global server subprocess — only touched from the asyncio event loop.
_server_proc: subprocess.Popen | None = None
_cfg = None # Config instance, set in run()
# ── Socket command handlers ────────────────────────────────────────────────────
async def _handle_serve_start(host: str, port: int) -> dict:
global _server_proc
if _server_proc is not None and _server_proc.poll() is None:
return {"ok": False, "error": "web server already running", "pid": _server_proc.pid}
cmd = [sys.executable, "-m", "nvcurve", "serve", "start",
"--host", host, "--port", str(port), "--direct"]
log_path = "/var/log/nvcurve-server.log"
log.info("Starting web server on %s:%d (log: %s)", host, port, log_path)
with open(log_path, "a") as lf:
_server_proc = subprocess.Popen(
cmd,
stdout=lf,
stderr=lf,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
)
log.info("Web server started (PID %d)", _server_proc.pid)
return {"ok": True, "pid": _server_proc.pid}
async def _handle_serve_stop() -> dict:
global _server_proc
if _server_proc is None or _server_proc.poll() is not None:
_server_proc = None
return {"ok": False, "error": "web server is not running"}
log.info("Stopping web server (PID %d)…", _server_proc.pid)
_server_proc.terminate()
try:
await asyncio.get_running_loop().run_in_executor(
None, _server_proc.wait, 5
)
except Exception:
_server_proc.kill()
_server_proc = None
return {"ok": True}
async def _handle_serve_status() -> dict:
global _server_proc
if _server_proc is not None and _server_proc.poll() is None:
return {"ok": True, "running": True, "pid": _server_proc.pid}
_server_proc = None
return {"ok": True, "running": False}
async def _dispatch(req: dict) -> dict:
cmd = req.get("cmd")
if cmd == "ping":
return {"ok": True}
elif cmd == "serve_start":
host = req.get("host", _cfg.host)
port = req.get("port", _cfg.port)
return await _handle_serve_start(host, port)
elif cmd == "serve_stop":
return await _handle_serve_stop()
elif cmd == "serve_status":
return await _handle_serve_status()
else:
return {"ok": False, "error": f"unknown command: {cmd!r}"}
async def _handle_client(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
resp: dict = {"ok": False, "error": "internal error"}
try:
data = await asyncio.wait_for(reader.readline(), timeout=5.0)
req = json.loads(data)
resp = await _dispatch(req)
except asyncio.TimeoutError:
resp = {"ok": False, "error": "timeout reading request"}
except json.JSONDecodeError as exc:
resp = {"ok": False, "error": f"invalid JSON: {exc}"}
except Exception as exc:
resp = {"ok": False, "error": str(exc)}
finally:
try:
writer.write(json.dumps(resp).encode() + b"\n")
await writer.drain()
except Exception:
pass
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
# ── Entrypoint ─────────────────────────────────────────────────────────────────
def _load_persistent_config() -> dict:
try:
with open(_PERSISTENT_CONFIG_FILE) as f:
return json.load(f)
except Exception:
return {}
def run() -> None:
"""Run the nvcurve daemon: apply auto-load profiles, then serve the Unix socket."""
global _cfg
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if os.geteuid() != 0:
print("nvcurve daemon: must run as root", file=sys.stderr)
sys.exit(1)
cfg_data = _load_persistent_config()
from .config import Config
_cfg = Config()
for key in ("max_delta_khz", "auto_snapshot", "max_snapshots",
"snapshot_dir", "profile_dir", "host", "port"):
if key in cfg_data:
setattr(_cfg, key, cfg_data[key])
# Apply auto-load profiles in a subprocess so the daemon process itself
# never loads NvAPI/NVML/HAL modules — keeps steady-state RSS low.
auto_load_profiles: dict = cfg_data.get("auto_load_profiles", {})
if auto_load_profiles:
log.info("Applying auto-load profiles…")
subprocess.run(
[sys.executable, "-m", "nvcurve", "autoload"],
check=False,
)
auto_serve: bool = cfg_data.get("auto_serve", False)
asyncio.run(_serve_socket(auto_serve=auto_serve))
log.info("Daemon stopped.")
async def _serve_socket(auto_serve: bool = False) -> None:
global _server_proc
# Clean up stale socket from a previous (unclean) run.
if os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH)
os.chmod(SOCKET_PATH, 0o666) # allow non-root CLI to connect
log.info("Daemon listening on %s", SOCKET_PATH)
if auto_serve:
log.info("auto_serve enabled — starting web server on boot")
await _handle_serve_start(_cfg.host, _cfg.port)
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGTERM, stop_event.set)
loop.add_signal_handler(signal.SIGINT, stop_event.set)
async with server:
await stop_event.wait()
# Gracefully stop the web server if it's running.
if _server_proc is not None and _server_proc.poll() is None:
log.info("Stopping web server (PID %d)…", _server_proc.pid)
_server_proc.terminate()
try:
await asyncio.wait_for(
asyncio.get_running_loop().run_in_executor(None, _server_proc.wait),
timeout=5,
)
except asyncio.TimeoutError:
_server_proc.kill()
if os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
+13
View File
@@ -0,0 +1,13 @@
from .gpu import get_gpu, discover_gpus
from .vfcurve import read_curve, read_clock_offsets, write_offsets, reset_offsets
from .monitoring import poll, read_voltage
from .ranges import get_clock_ranges
from .snapshot import save as snapshot_save, restore as snapshot_restore, list_snapshots
__all__ = [
"get_gpu", "discover_gpus",
"read_curve", "read_clock_offsets", "write_offsets", "reset_offsets",
"poll", "read_voltage",
"get_clock_ranges",
"snapshot_save", "snapshot_restore", "list_snapshots",
]
+93
View File
@@ -0,0 +1,93 @@
"""GPU discovery and initialization."""
import ctypes
import sys
from ..nvapi.bootstrap import query_interface
from ..nvapi.constants import FUNC
from ..nvapi.types import GpuInfo
def init_nvapi() -> None:
"""Initialize NvAPI. Must be called before any GPU operations."""
init_fn = query_interface(FUNC["Initialize"], nargs=0)
if not init_fn or init_fn() != 0:
print("NvAPI_Initialize failed")
sys.exit(1)
def enumerate_gpus() -> tuple[ctypes.Array, int]:
"""Return (gpu_handles_array, count). Exits if no GPUs found."""
gpus = (ctypes.c_void_p * 64)()
ngpu = ctypes.c_int32()
query_interface(FUNC["EnumPhysicalGPUs"])(ctypes.byref(gpus), ctypes.byref(ngpu))
if ngpu.value == 0:
print("No NVIDIA GPUs found")
sys.exit(1)
return gpus, ngpu.value
def get_gpu_name(gpu) -> str:
"""Return the full name string for a GPU handle."""
name_buf = ctypes.create_string_buffer(256)
query_interface(FUNC["GetFullName"])(gpu, name_buf)
return name_buf.value.decode(errors="replace")
def discover_gpus() -> list[GpuInfo]:
"""Initialize NvAPI and NVML, and return a list of GpuInfo for all physical GPUs."""
init_nvapi()
gpus, count = enumerate_gpus()
infos = []
try:
import pynvml
pynvml.nvmlInit()
has_nvml = True
except Exception:
has_nvml = False
for i in range(count):
name = get_gpu_name(gpus[i])
uuid = None
pci_bus_id = None
if has_nvml:
try:
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
uuid = pynvml.nvmlDeviceGetUUID(handle)
# NVML might return bytes
if isinstance(uuid, bytes):
uuid = uuid.decode('utf-8', errors='ignore')
pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
# Parse something like "00000000:01:00.0" -> bus is 1
if isinstance(pci_info.bus, bytes):
pci_bus_id = int(pci_info.bus.decode('utf-8', errors='ignore'), 16)
else:
pci_bus_id = pci_info.bus
except Exception:
pass
infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id))
if has_nvml:
try:
pynvml.nvmlShutdown()
except Exception:
pass
return infos
def get_gpu(index: int = 0):
"""Initialize NvAPI, enumerate GPUs, and return the handle for `index`.
Also returns the GPU name as a convenience.
Returns (handle, name).
"""
init_nvapi()
gpus, count = enumerate_gpus()
if index >= count:
print(f"GPU index {index} out of range (found {count} GPU(s))")
sys.exit(1)
gpu = gpus[index]
name = get_gpu_name(gpu)
return gpu, name
+321
View File
@@ -0,0 +1,321 @@
"""Hardware Abstraction Layer for Global Limits (Power, Clock Offsets).
Uses NVML (via pynvml) for all operations.
Clock offsets use nvmlDeviceSetClockOffsets / nvmlDeviceGetClockOffsets
(introduced in driver 555.85). The older per-domain functions
(nvmlDeviceSet/GetGpcClkVfOffset, nvmlDeviceSet/GetMemClkVfOffset) are used
as a fallback when the new API is unavailable or returns an error.
set_clock_offsets accepts Optional values and only touches the domains
that are explicitly specified, leaving others unchanged on hardware.
"""
import ctypes
import subprocess
import logging
from typing import Optional
try:
import pynvml
_NVML_AVAILABLE = True
except ImportError:
_NVML_AVAILABLE = False
log = logging.getLogger("nvcurve.hal.limits")
# ── NVML library / handle helpers ─────────────────────────────────────────────
_nvml_lib: Optional[ctypes.CDLL] = None
def _nvml_cdll() -> ctypes.CDLL:
"""Return a ctypes handle to libnvidia-ml, reusing pynvml's load if possible."""
global _nvml_lib
if _nvml_lib is not None:
return _nvml_lib
# Prefer to reuse the library already loaded by pynvml to avoid dlopen races.
for attr in ("nvml", "_nvml"): # attribute name varies by pynvml version
mod = getattr(pynvml, attr, None)
lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None)
if lib is not None:
_nvml_lib = lib
return _nvml_lib
_nvml_lib = ctypes.CDLL("libnvidia-ml.so.1")
return _nvml_lib
def _get_handle(gpu_index: int):
"""Return an NVML device handle, initialising pynvml if needed."""
if not _NVML_AVAILABLE:
raise RuntimeError("NVML not available (install nvidia-ml-py)")
return pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
# ── Power limit ───────────────────────────────────────────────────────────────
def get_power_limit(gpu_index: int = 0) -> dict:
"""Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w."""
out = {
"power_limit_w": None,
"default_power_limit_w": None,
"min_power_limit_w": None,
"max_power_limit_w": None,
}
try:
handle = _get_handle(gpu_index)
limit = pynvml.nvmlDeviceGetPowerManagementLimit(handle)
constrs = pynvml.nvmlDeviceGetPowerManagementLimitConstraints(handle)
out["power_limit_w"] = limit // 1000
out["min_power_limit_w"] = constrs[0] // 1000
out["max_power_limit_w"] = constrs[1] // 1000
try:
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
out["default_power_limit_w"] = default // 1000
except Exception:
pass
except Exception as exc:
log.warning("get_power_limit: %s", exc)
return out
def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
"""Set the board power limit (Watts)."""
try:
handle = _get_handle(gpu_index)
pynvml.nvmlDeviceSetPowerManagementLimit(handle, limit_w * 1000)
return True, "OK"
except Exception as exc:
log.debug("NVML set_power_limit failed: %s — falling back to nvidia-smi", exc)
ret = subprocess.run(
["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)],
capture_output=True, text=True,
)
if ret.returncode == 0:
return True, "OK"
return False, ret.stderr.strip() or ret.stdout.strip()
# ── Clock offsets (GPC + memory) ──────────────────────────────────────────────
# The correct struct layout (per NVML docs and driver 590.x headers):
#
# typedef struct {
# unsigned int version; // nvmlClockOffset_v1
# nvmlClockType_t type; // NVML_CLOCK_GRAPHICS (0) or NVML_CLOCK_MEM (2)
# nvmlPstates_t pstate; // NVML_PSTATE_0 (0)
# int clockOffsetMHz;
# } nvmlClockOffset_t;
#
# nvmlDeviceSet/GetClockOffsets are called ONCE PER CLOCK DOMAIN.
# pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1
# as ctypes objects; we use them when available and fall back to our own definition.
class _ClockOffset(ctypes.Structure):
_fields_ = [
("version", ctypes.c_uint),
("type", ctypes.c_uint), # nvmlClockType_t
("pstate", ctypes.c_uint), # nvmlPstates_t
("clockOffsetMHz", ctypes.c_int),
]
_CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes)
# NVML clock-type constants (same values as pynvml).
_NVML_CLOCK_GRAPHICS = 0
_NVML_CLOCK_MEM = 2
def _make_clock_offset(clock_type: int, pstate: int = 0, offset_mhz: int = 0) -> ctypes.Structure:
"""Return a populated nvmlClockOffset_t struct, using pynvml's type when available."""
if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"):
info = pynvml.c_nvmlClockOffset_t()
info.version = pynvml.nvmlClockOffset_v1
info.type = clock_type
info.pstate = pstate
info.clockOffsetMHz = offset_mhz
return info
info = _ClockOffset()
info.version = _CLOCK_OFFSET_VER
info.type = clock_type
info.pstate = pstate
info.clockOffsetMHz = offset_mhz
return info
def _try_nvml_fn(name: str):
"""Return a ctypes-callable for an NVML function, or None if not found."""
lib = _nvml_cdll()
try:
return getattr(lib, name)
except AttributeError:
return None
def get_clock_offsets(gpu_index: int = 0) -> dict:
"""Return GPC and memory clock offsets (MHz).
Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure).
Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM).
"""
out = {"gpc_offset_mhz": None, "mem_offset_mhz": None}
if not _NVML_AVAILABLE:
return out
try:
handle = _get_handle(gpu_index)
# Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly).
# Fall back to ctypes-direct if pynvml doesn't have it.
_pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None)
fn_get = _try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None
used_new_api = False
for clock_type, key in ((_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
(_NVML_CLOCK_MEM, "mem_offset_mhz")):
info = _make_clock_offset(clock_type, pstate=0)
try:
if _pynvml_get is not None:
rc = _pynvml_get(handle, ctypes.byref(info))
elif fn_get is not None:
rc = fn_get(handle, ctypes.byref(info))
else:
break
if rc == 0:
out[key] = int(info.clockOffsetMHz)
used_new_api = True
else:
log.debug("nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc)
except Exception as exc:
log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc)
if used_new_api:
return out
# Deprecated per-domain fallback.
if hasattr(pynvml, "nvmlDeviceGetGpcClkVfOffset"):
try:
out["gpc_offset_mhz"] = int(pynvml.nvmlDeviceGetGpcClkVfOffset(handle))
except Exception as exc:
log.debug("nvmlDeviceGetGpcClkVfOffset: %s", exc)
if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"):
try:
res = pynvml.nvmlDeviceGetMemClkVfOffset(handle)
out["mem_offset_mhz"] = int(res[0] if isinstance(res, (list, tuple)) else res)
except Exception as exc:
log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc)
except Exception as exc:
log.warning("get_clock_offsets: %s", exc)
return out
def set_clock_offsets(
gpc_offset_mhz: Optional[int] = None,
mem_offset_mhz: Optional[int] = None,
gpu_index: int = 0,
) -> tuple[bool, str]:
"""Set clock offsets (MHz) for the specified domains only.
Pass None for a domain to leave it untouched on hardware.
Calls nvmlDeviceSetClockOffsets once per requested domain (GRAPHICS, MEM).
Falls back to deprecated per-domain functions when the new API returns an
error (e.g. NVML_ERROR_DEPRECATED=25 on Blackwell with driver 590.x).
"""
if gpc_offset_mhz is None and mem_offset_mhz is None:
return True, "OK"
if not _NVML_AVAILABLE:
return False, "NVML not available (install nvidia-ml-py)"
try:
handle = _get_handle(gpu_index)
domains = []
if gpc_offset_mhz is not None:
domains.append((_NVML_CLOCK_GRAPHICS, gpc_offset_mhz))
if mem_offset_mhz is not None:
domains.append((_NVML_CLOCK_MEM, mem_offset_mhz))
_pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None)
fn_set = _try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None
if _pynvml_set is not None or fn_set is not None:
all_ok = True
for clock_type, offset in domains:
info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset)
try:
rc = _pynvml_set(handle, ctypes.byref(info)) if _pynvml_set else fn_set(handle, ctypes.byref(info))
if rc != 0:
log.debug("nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback", clock_type, rc)
all_ok = False
break
except Exception as exc:
log.debug("nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback", clock_type, exc)
all_ok = False
break
if all_ok:
return True, "OK"
# Non-zero rc (e.g. 25=DEPRECATED on Blackwell) — fall through to deprecated path.
# Deprecated per-domain fallback (works on Blackwell/driver 590.x).
errs = []
if gpc_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetGpcClkVfOffset"):
try:
pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz)
except Exception as exc:
errs.append(f"GPC: {exc}")
if mem_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetMemClkVfOffset"):
try:
pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz)
except Exception as exc:
errs.append(f"MEM: {exc}")
if errs:
return False, "; ".join(errs)
return True, "OK"
except Exception as exc:
log.warning("set_clock_offsets: %s", exc)
return False, str(exc)
# ── Range queries ─────────────────────────────────────────────────────────────
def get_mem_offset_range(gpu_index: int = 0) -> dict:
"""Return the min/max allowed memory clock offset (MHz).
Keys: min_mem_offset_mhz, max_mem_offset_mhz.
Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values.
"""
# Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000).
out = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000}
if not _NVML_AVAILABLE:
return out
try:
handle = _get_handle(gpu_index)
if hasattr(pynvml, "nvmlDeviceGetMemClkMinMaxVfOffset"):
result = pynvml.nvmlDeviceGetMemClkMinMaxVfOffset(handle)
if isinstance(result, (list, tuple)) and len(result) >= 2:
out["min_mem_offset_mhz"] = int(result[0])
out["max_mem_offset_mhz"] = int(result[1])
else:
min_v = getattr(result, "minOffset", None)
max_v = getattr(result, "maxOffset", None)
if min_v is not None:
out["min_mem_offset_mhz"] = int(min_v)
if max_v is not None:
out["max_mem_offset_mhz"] = int(max_v)
return out
fn = _try_nvml_fn("nvmlDeviceGetMemClkMinMaxVfOffset")
if fn is not None:
min_v = ctypes.c_int(0)
max_v = ctypes.c_int(0)
rc = fn(handle, ctypes.byref(min_v), ctypes.byref(max_v))
if rc == 0:
out["min_mem_offset_mhz"] = int(min_v.value)
out["max_mem_offset_mhz"] = int(max_v.value)
except Exception as exc:
log.debug("get_mem_offset_range: %s", exc)
return out
+124
View File
@@ -0,0 +1,124 @@
"""Live GPU monitoring.
Voltage is read via NvAPI GetCurrentVoltage.
Clock, temperature, power draw, and fan speed are read via NVML (nvidia-ml-py).
"""
import struct
import time
from typing import Optional
from ..nvapi.bootstrap import nvcall
from ..nvapi.constants import FUNC, VOLT_SIZE
from ..nvapi.types import MonitoringSample
try:
import pynvml as _pynvml
_NVML_AVAILABLE = True
except ImportError:
_pynvml = None
_NVML_AVAILABLE = False
_nvml_initialized = False
def init_nvml() -> bool:
"""Initialize NVML. Call once at startup. Returns True on success."""
global _nvml_initialized
if not _NVML_AVAILABLE:
return False
try:
_pynvml.nvmlInit()
_nvml_initialized = True
return True
except _pynvml.NVMLError:
return False
def shutdown_nvml() -> None:
"""Shut down NVML. Call at process exit."""
global _nvml_initialized
if _NVML_AVAILABLE and _nvml_initialized:
try:
_pynvml.nvmlShutdown()
except _pynvml.NVMLError:
pass
_nvml_initialized = False
def get_driver_version() -> Optional[str]:
"""Return the NVIDIA driver version string, or None if unavailable."""
if not (_NVML_AVAILABLE and _nvml_initialized):
return None
try:
return _pynvml.nvmlSystemGetDriverVersion()
except _pynvml.NVMLError:
return None
def get_vram_total(gpu_index: int = 0) -> Optional[int]:
"""Return total VRAM in bytes, or None if unavailable."""
if not (_NVML_AVAILABLE and _nvml_initialized):
return None
try:
handle = _pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
return _pynvml.nvmlDeviceGetMemoryInfo(handle).total
except _pynvml.NVMLError:
return None
def read_voltage(gpu) -> tuple[Optional[int], str]:
"""Read current GPU core voltage in µV via NvAPI GetCurrentVoltage.
Returns (voltage_uV, "OK") or (None, error).
"""
d, err = nvcall(FUNC["GetCurrentVoltage"], gpu, VOLT_SIZE, ver=1)
if not d:
return None, err
return struct.unpack_from("<I", d, 0x28)[0], "OK"
def _nvml_read(gpu_index: int) -> dict:
"""Read all NVML fields. Returns a dict with keys matching MonitoringSample fields."""
out = {
"clock_mhz": None, "temp_c": None, "power_w": None, "fan_pct": None,
"pstate": None, "mem_used_bytes": None, "mem_total_bytes": None,
"gpu_util_pct": None, "mem_util_pct": None, "mem_clock_mhz": None,
}
if not (_NVML_AVAILABLE and _nvml_initialized):
return out
try:
handle = _pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
out["clock_mhz"] = float(_pynvml.nvmlDeviceGetClockInfo(handle, _pynvml.NVML_CLOCK_GRAPHICS))
out["mem_clock_mhz"] = float(_pynvml.nvmlDeviceGetClockInfo(handle, _pynvml.NVML_CLOCK_MEM))
out["temp_c"] = float(_pynvml.nvmlDeviceGetTemperature(handle, _pynvml.NVML_TEMPERATURE_GPU))
out["power_w"] = _pynvml.nvmlDeviceGetPowerUsage(handle) / 1000.0 # mW → W
out["pstate"] = int(_pynvml.nvmlDeviceGetPerformanceState(handle))
mem = _pynvml.nvmlDeviceGetMemoryInfo(handle)
out["mem_used_bytes"] = mem.used
out["mem_total_bytes"] = mem.total
util = _pynvml.nvmlDeviceGetUtilizationRates(handle)
out["gpu_util_pct"] = float(util.gpu)
out["mem_util_pct"] = float(util.memory)
try:
out["fan_pct"] = float(_pynvml.nvmlDeviceGetFanSpeed(handle))
except _pynvml.NVMLError:
pass
except _pynvml.NVMLError:
pass
return out
def poll(gpu, gpu_index: int = 0) -> MonitoringSample:
"""Read all available monitoring data and return a MonitoringSample."""
voltage_uv, _ = read_voltage(gpu)
nvml = _nvml_read(gpu_index)
return MonitoringSample(
timestamp=time.time(),
voltage_uv=voltage_uv,
**nvml,
)
+31
View File
@@ -0,0 +1,31 @@
"""Clock boost range queries."""
import struct
from typing import Optional
from ..nvapi.bootstrap import nvcall
from ..nvapi.constants import FUNC, RANGES_SIZE
def get_clock_ranges(gpu) -> tuple[Optional[dict], str]:
"""Read clock domain min/max offset ranges via GetClockBoostRanges.
Returns ({"num_domains": int, "domains": [[int, ...], ...]}, "OK")
or (None, error).
On RTX 5090: GPU core ±3000 MHz, memory -3000/+3000 MHz.
"""
d, err = nvcall(FUNC["GetClockBoostRanges"], gpu, RANGES_SIZE, ver=1)
if not d:
return None, err
num = struct.unpack_from("<I", d, 4)[0]
domains = []
for i in range(min(num, 32)):
base = 0x08 + i * 0x48
if base + 0x48 > len(d):
break
words = [struct.unpack_from("<i", d, base + j)[0] for j in range(0, 0x48, 4)]
domains.append(words)
return {"num_domains": num, "domains": domains}, "OK"
+156
View File
@@ -0,0 +1,156 @@
"""Save and restore ClockBoostTable snapshots to/from disk."""
import ctypes
import json
import os
import struct
from datetime import datetime
from typing import Optional
from ..nvapi.bootstrap import nvcall_raw
from ..nvapi.constants import FUNC, CT_SIZE, CT_BASE, CT_STRIDE, CT_DELTA_OFF, CT_POINTS
from ..nvapi.types import SnapshotInfo
from .vfcurve import read_clock_table_raw, get_boost_mask
def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optional[str]:
"""Save the current ClockBoostTable to disk.
Writes both a binary .bin file and a human-readable .json metadata file.
If max_snapshots > 0, deletes the oldest snapshots to stay within the limit.
Returns the binary filepath on success, or None on failure.
"""
raw, err = read_clock_table_raw(gpu)
if not raw:
print(f"Failed to read ClockBoostTable: {err}")
return None
os.makedirs(snapshot_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin")
meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json")
with open(bin_path, "wb") as f:
f.write(raw)
offsets = []
max_entries = (len(raw) - CT_BASE) // CT_STRIDE
for i in range(max_entries):
off = CT_BASE + i * CT_STRIDE + CT_DELTA_OFF
delta = struct.unpack_from("<i", raw, off)[0]
offsets.append(delta)
meta = {
"gpu": gpu_name,
"timestamp": datetime.now().isoformat(),
"file": bin_path,
"size": len(raw),
"offsets_kHz": offsets,
"nonzero_offsets": sum(1 for o in offsets if o != 0),
}
with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
print(f"Snapshot saved:")
print(f" Binary: {bin_path}")
print(f" Metadata: {meta_path}")
print(f" Size: {len(raw)} bytes")
print(f" Non-zero offsets: {meta['nonzero_offsets']}")
if max_snapshots > 0:
_prune_snapshots(snapshot_dir, max_snapshots)
return bin_path
def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None:
"""Delete oldest snapshots (both .bin and .json) to stay within max_snapshots."""
bins = sorted(
f for f in os.listdir(snapshot_dir) if f.endswith(".bin")
) # oldest first (lexicographic = chronological for our timestamp format)
excess = len(bins) - max_snapshots
for fname in bins[:excess]:
stem = fname[:-4] # strip .bin
for ext in (".bin", ".json"):
try:
os.remove(os.path.join(snapshot_dir, stem + ext))
except OSError:
pass
def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
"""Restore a ClockBoostTable snapshot from disk.
If no filepath is given, uses the most recent snapshot in snapshot_dir.
Returns True on success.
"""
if filepath is None:
if not os.path.isdir(snapshot_dir):
print(f"No snapshots found in {snapshot_dir}")
return False
bins = sorted(
[f for f in os.listdir(snapshot_dir) if f.endswith(".bin")],
reverse=True,
)
if not bins:
print(f"No snapshot .bin files in {snapshot_dir}")
return False
filepath = os.path.join(snapshot_dir, bins[0])
if not os.path.isfile(filepath):
print(f"Snapshot file not found: {filepath}")
return False
with open(filepath, "rb") as f:
raw = f.read()
if len(raw) != CT_SIZE:
print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}")
return False
vw = struct.unpack_from("<I", raw, 0)[0]
expected_vw = (1 << 16) | CT_SIZE
if vw != expected_vw:
print(f"Version word mismatch: 0x{vw:08X} (expected 0x{expected_vw:08X})")
return False
buf = ctypes.create_string_buffer(CT_SIZE)
ctypes.memmove(buf, raw, CT_SIZE)
# Full mask for restore — write all points
mask, _ = get_boost_mask(gpu)
if mask:
for i in range(32):
buf[4 + i] = mask[i]
print(f"Restoring from: {filepath}")
ret, desc = nvcall_raw(FUNC["SetClockBoostTable"], gpu, buf)
print(f"SetClockBoostTable returned: {ret} ({desc})")
return ret == 0
def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
"""Return metadata for all snapshots in snapshot_dir, newest first."""
if not os.path.isdir(snapshot_dir):
return []
results = []
for fname in sorted(os.listdir(snapshot_dir), reverse=True):
if not fname.endswith(".json"):
continue
meta_path = os.path.join(snapshot_dir, fname)
try:
with open(meta_path) as f:
meta = json.load(f)
bin_path = meta.get("file", meta_path.replace(".json", ".bin"))
results.append(SnapshotInfo(
filepath=bin_path,
timestamp=meta.get("timestamp", ""),
gpu=meta.get("gpu", ""),
nonzero_offsets=meta.get("nonzero_offsets", 0),
size=meta.get("size", 0),
))
except (json.JSONDecodeError, KeyError):
continue
return results
+286
View File
@@ -0,0 +1,286 @@
"""Read and write the GPU V/F curve via NvAPI."""
import ctypes
import struct
from typing import Optional
from ..nvapi.bootstrap import nvcall, nvcall_raw
from ..nvapi.constants import (
FUNC,
VFP_SIZE, VFP_BASE, VFP_STRIDE,
CT_SIZE, CT_BASE, CT_STRIDE, CT_DELTA_OFF, CT_POINTS,
)
from ..nvapi.types import VFPoint, CurveState
# ── Mask helpers ─────────────────────────────────────────────────────────────
# The boost mask is a static property of the GPU/driver — it does not change
# at runtime. Cache it per GPU handle to avoid redundant GetClockBoostMask
# calls on every HAL operation.
_boost_mask_cache: dict[int, bytes] = {}
def get_boost_mask(gpu) -> tuple[Optional[bytes], str]:
"""Read the canonical 32-byte clock boost mask from the driver.
The result is cached per GPU handle: subsequent calls return the cached
value without hitting the driver again.
Returns (mask_bytes, "OK") or (None, error).
"""
if gpu in _boost_mask_cache:
return _boost_mask_cache[gpu], "OK"
from ..nvapi.constants import MASK_SIZE
def fill(b):
for i in range(4, 4 + 32):
b[i] = 0xFF
d, err = nvcall(FUNC["GetClockBoostMask"], gpu, MASK_SIZE, ver=1, pre_fill=fill)
if d and len(d) >= 36:
mask = d[4:36]
_boost_mask_cache[gpu] = mask
return mask, "OK"
return None, err
def set_mask_bit(buf, point: int, offset: int = 4) -> None:
"""Set a single bit in the 256-bit mask for one point."""
byte_idx = offset + (point // 8)
bit_idx = point % 8
buf[byte_idx] = int.from_bytes(buf[byte_idx:byte_idx + 1], "little") | (1 << bit_idx)
def set_mask_bits(buf, points: set[int], offset: int = 4) -> None:
"""Set mask bits for a set of points."""
for p in points:
set_mask_bit(buf, p, offset)
# ── Readers ───────────────────────────────────────────────────────────────────
def read_vfp_curve(gpu) -> tuple[Optional[list[tuple[int, int]]], str]:
"""Read the base V/F curve (frequency + voltage pairs).
Returns ([(freq_kHz, volt_uV), ...], "OK") or (None, error).
"""
mask, mask_err = get_boost_mask(gpu)
if not mask:
return None, f"GetClockBoostMask failed: {mask_err}"
def fill(buf):
for i in range(32):
buf[4 + i] = mask[i]
d, err = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill)
if not d:
return None, err
points = []
max_entries = (len(d) - VFP_BASE) // VFP_STRIDE
for i in range(max_entries):
off = VFP_BASE + i * VFP_STRIDE
freq = struct.unpack_from("<I", d, off)[0]
volt = struct.unpack_from("<I", d, off + 4)[0]
points.append((freq, volt))
return points, "OK"
def read_clock_table_raw(gpu) -> tuple[Optional[bytes], str]:
"""Read the raw ClockBoostTable buffer.
Used for snapshots, inspection, and as the baseline for writes.
Returns (bytes, "OK") or (None, error).
"""
mask, mask_err = get_boost_mask(gpu)
if not mask:
return None, f"GetClockBoostMask failed: {mask_err}"
def fill(buf):
for i in range(32):
buf[4 + i] = mask[i]
return nvcall(FUNC["GetClockBoostTable"], gpu, CT_SIZE, ver=1, pre_fill=fill)
def read_clock_table_parsed(gpu) -> tuple[Optional[list[tuple[int, int]]], str]:
"""Read per-point offsets and flags from the ClockBoostTable.
Returns a list of (delta_kHz, flags) tuples, or (None, error).
"""
d, err = read_clock_table_raw(gpu)
if not d:
return None, err
entries = []
max_entries = (len(d) - CT_BASE) // CT_STRIDE
for i in range(max_entries):
base_off = CT_BASE + i * CT_STRIDE
flags = struct.unpack_from("<I", d, base_off)[0]
delta = struct.unpack_from("<i", d, base_off + CT_DELTA_OFF)[0]
entries.append((delta, flags))
return entries, "OK"
def read_clock_offsets(gpu) -> tuple[Optional[list[int]], str]:
"""Read per-point frequency offsets (kHz, signed) from the ClockBoostTable.
Returns a list of integers, or (None, error).
"""
parsed, err = read_clock_table_parsed(gpu)
if not parsed:
return None, err
offsets = [delta for delta, flags in parsed]
return offsets, "OK"
def read_clock_entry_full(data: bytes, point: int) -> dict:
"""Extract all 9 raw fields from a single ClockBoostTable entry.
Useful for diagnostics and verifying unknown fields.
"""
base = CT_BASE + point * CT_STRIDE
fields = {}
for j in range(9):
off = base + j * 4
if j == 5: # freqDelta is signed
fields[f"field_{j:02d}_0x{j * 4:02X}"] = struct.unpack_from("<i", data, off)[0]
else:
fields[f"field_{j:02d}_0x{j * 4:02X}"] = struct.unpack_from("<I", data, off)[0]
fields["freqDelta_kHz"] = fields["field_05_0x14"]
return fields
def read_curve(gpu, gpu_name: str = "") -> tuple[Optional[CurveState], str]:
"""Read both the VFP curve and ClockBoostTable and merge into CurveState.
Returns (CurveState, "OK") or (None, error).
"""
import time
vfp_points, vfp_err = read_vfp_curve(gpu)
if not vfp_points:
return None, vfp_err
ct_entries, ct_err = read_clock_table_parsed(gpu)
if not ct_entries:
return None, ct_err
points = []
in_memory = False
for i, (freq_khz, volt_uv) in enumerate(vfp_points):
if freq_khz == 0 and volt_uv == 0:
break # end of populated entries
delta_khz = ct_entries[i][0] if i < len(ct_entries) else 0
flags = ct_entries[i][1] if i < len(ct_entries) else 0
if flags == 1:
in_memory = True
points.append(VFPoint(
index=i,
freq_khz=freq_khz,
volt_uv=volt_uv,
delta_khz=delta_khz,
domain="memory" if in_memory else "gpu",
))
return CurveState(points=points, timestamp=time.time(), gpu_name=gpu_name), "OK"
# ── Writers ───────────────────────────────────────────────────────────────────
def build_write_buffer(
gpu,
point_deltas: dict[int, int],
full_mask: bool = False,
) -> tuple[Optional[ctypes.Array], str]:
"""Build a SetClockBoostTable buffer with specified per-point deltas.
Strategy: read the current ClockBoostTable, modify only the targeted
entries' freqDelta fields, set only the targeted mask bits (single-bit per
write to avoid touching neighbouring points).
Args:
full_mask: if True, copy the complete GetClockBoostMask into the write
buffer instead of the default sparse (per-point) mask.
Older GPUs (e.g. Pascal) may require this.
Returns (mutable_buffer, "OK") or (None, error).
"""
current_raw, err = read_clock_table_raw(gpu)
if not current_raw:
return None, f"Cannot read current ClockBoostTable: {err}"
buf = ctypes.create_string_buffer(CT_SIZE)
ctypes.memmove(buf, current_raw, CT_SIZE)
# Rewrite version word explicitly
struct.pack_into("<I", buf, 0, (1 << 16) | CT_SIZE)
if full_mask:
# Copy the complete boost mask — required by some older drivers/GPUs
mask, mask_err = get_boost_mask(gpu)
if not mask:
return None, f"Cannot read boost mask: {mask_err}"
for i, b in enumerate(mask):
buf[4 + i] = b
else:
# Sparse mask — set only bits for points we're writing
for i in range(4, 4 + 32):
buf[i] = 0x00
set_mask_bits(buf, set(point_deltas.keys()))
for point, delta_khz in point_deltas.items():
off = CT_BASE + point * CT_STRIDE + CT_DELTA_OFF
struct.pack_into("<i", buf, off, delta_khz)
return buf, "OK"
def write_offsets(
gpu,
point_deltas: dict[int, int],
dry_run: bool = False,
full_mask: bool = False,
) -> tuple[int, str]:
"""Write per-point frequency offsets via SetClockBoostTable.
Args:
gpu: NvAPI GPU handle
point_deltas: {point_index: delta_kHz} — only these points are written
dry_run: if True, build the buffer but don't call the driver
full_mask: if True, use the full GetClockBoostMask (for older GPUs)
Returns (return_code, description).
"""
buf, err = build_write_buffer(gpu, point_deltas, full_mask=full_mask)
if buf is None:
return -999, err
if dry_run:
return 0, "DRY RUN — buffer built but not sent to driver"
return nvcall_raw(FUNC["SetClockBoostTable"], gpu, buf)
def write_global_offset(gpu, delta_khz: int, dry_run: bool = False) -> tuple[int, str]:
"""Apply a uniform frequency offset to all GPU core points."""
curve, err = read_curve(gpu)
if not curve:
return -999, f"Failed to read curve: {err}"
point_deltas = {p.index: delta_khz for p in curve.points if p.domain == "gpu"}
return write_offsets(gpu, point_deltas, dry_run=dry_run)
def reset_offsets(gpu, dry_run: bool = False) -> tuple[int, str]:
"""Zero all GPU core frequency offsets."""
curve, err = read_curve(gpu)
if not curve:
return -999, f"Failed to read curve: {err}"
point_deltas = {p.index: 0 for p in curve.points if p.domain == "gpu"}
return write_offsets(gpu, point_deltas, dry_run=dry_run)
+11
View File
@@ -0,0 +1,11 @@
from .bootstrap import nvcall, nvcall_raw, query_interface
from .constants import FUNC
from .errors import NvAPIError, NVAPI_ERRORS
from .types import VFPoint, CurveState, MonitoringSample, GpuInfo, SnapshotInfo
__all__ = [
"nvcall", "nvcall_raw", "query_interface",
"FUNC",
"NvAPIError", "NVAPI_ERRORS",
"VFPoint", "CurveState", "MonitoringSample", "GpuInfo", "SnapshotInfo",
]
+81
View File
@@ -0,0 +1,81 @@
"""NvAPI bootstrap — library loading, function resolution, versioned struct calls."""
import ctypes
import struct
import sys
from .errors import NVAPI_ERRORS
def load_nvapi() -> ctypes.CDLL:
"""Load libnvidia-api.so from the NVIDIA driver."""
for name in ("libnvidia-api.so", "libnvidia-api.so.1"):
try:
return ctypes.CDLL(name)
except OSError:
continue
print("Error: Cannot load libnvidia-api.so")
print("Ensure the NVIDIA proprietary driver is installed.")
sys.exit(1)
# Module-level library handle and QueryInterface function pointer.
_nvapi = load_nvapi()
_QI = _nvapi.nvapi_QueryInterface
_QI.restype = ctypes.c_void_p
_QI.argtypes = [ctypes.c_uint32]
def query_interface(fid: int, nargs: int = 2):
"""Resolve an NvAPI function pointer by its 32-bit ID.
Returns a callable ctypes function, or None if the driver doesn't expose it.
"""
ptr = _QI(fid)
if not ptr:
return None
return ctypes.CFUNCTYPE(ctypes.c_int32, *[ctypes.c_void_p] * nargs)(ptr)
def nvcall(
fid: int,
gpu,
size: int,
ver: int = 1,
pre_fill=None,
) -> tuple[bytes | None, str]:
"""Call an NvAPI function with a versioned struct buffer.
Allocates a buffer of `size` bytes, writes the version word
``(ver << 16) | size`` at offset 0, optionally calls ``pre_fill(buf)``
to populate request fields, then invokes the function.
Returns ``(bytes, "OK")`` on success or ``(None, error_description)`` on
failure.
"""
func = query_interface(fid)
if not func:
return None, "function pointer not found (driver too old?)"
buf = ctypes.create_string_buffer(size)
struct.pack_into("<I", buf, 0, (ver << 16) | size)
if pre_fill:
pre_fill(buf)
ret = func(gpu, buf)
if ret != 0:
return None, f"error {ret} ({NVAPI_ERRORS.get(ret, 'unknown')})"
return bytes(buf), "OK"
def nvcall_raw(fid: int, gpu, buf: ctypes.Array) -> tuple[int, str]:
"""Call an NvAPI function with a pre-built mutable buffer.
Used for write operations where the caller needs full control over the
buffer contents (e.g. SetClockBoostTable).
Returns ``(return_code, description)``.
"""
func = query_interface(fid)
if not func:
return -999, "function pointer not found (driver too old?)"
ret = func(gpu, buf)
return ret, NVAPI_ERRORS.get(ret, f"unknown ({ret})")
+55
View File
@@ -0,0 +1,55 @@
# NvAPI function IDs, struct sizes, offsets, and safety constants.
# All values verified by hex-dump analysis on RTX 5090 (GB202), driver 590.48.01.
# ── Function IDs ────────────────────────────────────────────────────────────
FUNC = {
# Bootstrap
"Initialize": 0x0150E828,
"EnumPhysicalGPUs": 0xE5AC921F,
"GetFullName": 0xCEEE8E9F,
# V/F curve (read)
"GetVFPCurve": 0x21537AD4, # ClkVfPointsGetStatus
"GetClockBoostMask": 0x507B4B59, # ClkVfPointsGetInfo
"GetClockBoostTable": 0x23F1B133, # ClkVfPointsGetControl
"GetCurrentVoltage": 0x465F9BCF, # ClientVoltRailsGetStatus
"GetClockBoostRanges": 0x64B43A6A, # ClkDomainsGetInfo
# Additional read
"GetPerfLimits": 0xE440B867, # PerfClientLimitsGetStatus
"GetVoltBoostPercent": 0x9DF23CA1, # ClientVoltRailsGetControl
# Write
"SetClockBoostTable": 0x0733E009, # ClkVfPointsSetControl
}
# ── GetVFPCurve (0x21537AD4) ─────────────────────────────────────────────────
# 128 × 28-byte entries at offset 0x48; freq_kHz at +0x00, volt_uV at +0x04.
VFP_SIZE = 0x1C28 # 7208 bytes
VFP_BASE = 0x48
VFP_STRIDE = 0x1C # 28 bytes
VFP_POINTS = (VFP_SIZE - VFP_BASE) // VFP_STRIDE
# ── Get/SetClockBoostTable (0x23F1B133 / 0x0733E009) ────────────────────────
# 128 × 36-byte entries at offset 0x44; freqDelta (int32, kHz) at entry+0x14.
# Correct layout verified against LACT issue #936 (which had wrong stride 0x48).
CT_SIZE = 0x2420 # 9248 bytes
CT_BASE = 0x44
CT_STRIDE = 0x24 # 36 bytes
CT_DELTA_OFF = 0x14 # freqDelta offset within entry
CT_POINTS = (CT_SIZE - CT_BASE) // CT_STRIDE
# ── Other struct sizes ───────────────────────────────────────────────────────
MASK_SIZE = 0x182C # GetClockBoostMask
VOLT_SIZE = 0x004C # GetCurrentVoltage (voltage µV at offset 0x28)
RANGES_SIZE = 0x0928 # GetClockBoostRanges
PERF_SIZE = 0x030C # GetPerfLimits (version 2, not 1)
VBOOST_SIZE = 0x0028 # GetVoltBoostPercent
# ── Safety ───────────────────────────────────────────────────────────────────
MAX_DELTA_KHZ = 3_000_000 # ±3000 MHz hard cap (Blackwell driver limit)
+27
View File
@@ -0,0 +1,27 @@
# NvAPI error codes and exception class.
# Linux NvAPI uses negative integers, not the 0x80000000+ range from Windows.
NVAPI_ERRORS: dict[int, str] = {
0: "OK",
-1: "GENERIC_ERROR",
-5: "INVALID_ARGUMENT",
-6: "NVIDIA_DEVICE_NOT_FOUND",
-7: "END_ENUMERATION",
-8: "INVALID_HANDLE",
-9: "INCOMPATIBLE_STRUCT_VERSION",
-10: "HANDLE_INVALIDATED",
-14: "INVALID_POINTER",
}
class NvAPIError(Exception):
"""Raised when an NvAPI call returns a non-zero error code."""
def __init__(self, code: int, context: str = ""):
self.code = code
self.name = NVAPI_ERRORS.get(code, f"unknown ({code})")
self.context = context
msg = f"NvAPI error {code} ({self.name})"
if context:
msg = f"{context}: {msg}"
super().__init__(msg)
+76
View File
@@ -0,0 +1,76 @@
"""Clean Python dataclasses for V/F curve data.
Raw struct manipulation stays in the call layer (bootstrap.py + hal/).
Everything above HAL works with these types.
"""
from dataclasses import dataclass, field
@dataclass
class VFPoint:
index: int
freq_khz: int # Base frequency from VFP curve
volt_uv: int # Voltage from VFP curve
delta_khz: int # Offset from ClockBoostTable (signed)
domain: str = "gpu" # "gpu" or "memory"
@property
def effective_freq_khz(self) -> int:
return self.freq_khz + self.delta_khz
@property
def freq_mhz(self) -> float:
return self.freq_khz / 1000.0
@property
def effective_freq_mhz(self) -> float:
return self.effective_freq_khz / 1000.0
@property
def volt_mv(self) -> float:
return self.volt_uv / 1000.0
@property
def delta_mhz(self) -> float:
return self.delta_khz / 1000.0
@dataclass
class CurveState:
points: list[VFPoint]
timestamp: float
gpu_name: str
@dataclass
class MonitoringSample:
timestamp: float
voltage_uv: int | None
clock_mhz: float | None
temp_c: float | None
power_w: float | None
fan_pct: float | None
pstate: int | None # Performance state: 0 (P0, max) – 15 (P15, min)
mem_used_bytes: int | None # VRAM used (bytes)
mem_total_bytes: int | None # VRAM total (bytes)
gpu_util_pct: float | None # GPU core utilization (0–100)
mem_util_pct: float | None # Memory bus utilization (0–100)
mem_clock_mhz: float | None = None # Current memory clock (NVML_CLOCK_MEM)
@dataclass
class GpuInfo:
name: str
index: int
uuid: str | None = None
pci_bus_id: int | None = None
@dataclass
class SnapshotInfo:
filepath: str
timestamp: str
gpu: str
nonzero_offsets: int
size: int
+1
View File
@@ -0,0 +1 @@
# Make profiles a package
+187
View File
@@ -0,0 +1,187 @@
"""Apply saved profiles to hardware, with optional read-back verification."""
import json
import logging
import os
import sys
import time
log = logging.getLogger("nvcurve.profiles.apply")
_PERSISTENT_CONFIG_FILE = "/etc/nvcurve/config.json"
def _gpu_stable_key(info) -> str:
if info.uuid:
return info.uuid
if info.pci_bus_id is not None:
return f"pci:{info.pci_bus_id:04x}"
return f"idx:{info.index}"
def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
"""Apply a named profile to the given GPU. Returns a list of error strings."""
from .native import load_profile
from ..hal.gpu import get_gpu
from ..hal.limits import set_clock_offsets, set_power_limit
from ..hal.vfcurve import write_offsets, reset_offsets
from ..hal.snapshot import save as snapshot_save
from ..safety import validate_write
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
profile = load_profile(filepath) # raises FileNotFoundError if missing
gpu, gpu_name = get_gpu(index=gpu_index)
errs: list[str] = []
# Apply mem offset first — driver may reset curve table as a side-effect.
if profile.mem_offset_mhz is not None:
ok, msg = set_clock_offsets(None, profile.mem_offset_mhz, gpu_index)
if not ok:
errs.append(f"Mem offset: {msg}")
if profile.power_limit_w is not None:
ok, msg = set_power_limit(profile.power_limit_w, gpu_index)
if not ok:
errs.append(f"Power limit: {msg}")
if profile.curve_deltas:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
errors = validate_write(deltas, cfg.max_delta_khz)
if errors:
errs.append("Curve: " + "; ".join(errors))
else:
if cfg.auto_snapshot:
try:
snapshot_save(gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots)
except Exception as exc:
log.warning("Auto-snapshot failed: %s", exc)
ret, desc = write_offsets(gpu, deltas)
if ret != 0:
errs.append(f"Curve write failed ({ret}): {desc}")
else:
reset_offsets(gpu)
return errs
def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool:
"""Apply a named profile with read-back verification, retrying on mismatch."""
from .native import load_profile
from ..hal.gpu import get_gpu
from ..hal.vfcurve import read_clock_offsets
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
try:
profile = load_profile(filepath)
except FileNotFoundError:
log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index)
return False
expected: dict[int, int] = (
{int(k): v for k, v in profile.curve_deltas.items()}
if profile.curve_deltas else {}
)
for attempt in range(max_retries):
try:
errs = apply_profile(gpu_index, name, cfg)
except Exception as exc:
log.warning("Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc)
errs = [str(exc)]
if errs:
log.warning("Auto-load attempt %d/%d errors: %s",
attempt + 1, max_retries, "; ".join(errs))
elif expected:
gpu, _ = get_gpu(index=gpu_index)
offsets, err = read_clock_offsets(gpu)
if offsets is None:
log.warning("Auto-load attempt %d/%d: read-back failed: %s",
attempt + 1, max_retries, err)
else:
mismatches = [
f"pt{idx}: expected {val/1000:+.0f}MHz got {offsets[idx]/1000:+.0f}MHz"
for idx, val in expected.items()
if idx < len(offsets) and offsets[idx] != val
]
if not mismatches:
log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)",
name, gpu_index, attempt + 1, max_retries)
return True
log.warning("Auto-load attempt %d/%d: read-back mismatch — %s",
attempt + 1, max_retries, "; ".join(mismatches))
else:
log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)",
name, gpu_index, attempt + 1, max_retries)
return True
if attempt < max_retries - 1:
delay = 2 ** attempt # 1s, 2s, 4s
log.info("Retrying auto-load in %ds…", delay)
time.sleep(delay)
log.warning("Auto-load profile %r failed after %d attempts on GPU %d",
name, max_retries, gpu_index)
return False
def run_autoload() -> None:
"""Read config and apply all configured auto-load profiles. Requires root."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if os.geteuid() != 0:
print("nvcurve autoload: must run as root", file=sys.stderr)
sys.exit(1)
try:
with open(_PERSISTENT_CONFIG_FILE) as f:
cfg_data = json.load(f)
except Exception:
cfg_data = {}
auto_load_profiles: dict = cfg_data.get("auto_load_profiles", {})
if not auto_load_profiles:
log.info("No auto-load profiles configured.")
return
from ..config import Config
cfg = Config()
for key in ("max_delta_khz", "auto_snapshot", "max_snapshots",
"snapshot_dir", "profile_dir"):
if key in cfg_data:
setattr(cfg, key, cfg_data[key])
from ..hal.gpu import init_nvapi, discover_gpus
from ..hal.monitoring import init_nvml, shutdown_nvml
try:
init_nvapi()
except Exception as exc:
log.error("Failed to initialize NvAPI: %s", exc)
sys.exit(1)
init_nvml() # best-effort
gpus = discover_gpus()
if not gpus:
log.warning("No GPUs discovered.")
key_to_idx = {_gpu_stable_key(info): info.index for info in gpus}
for gpu_key, profile_name in auto_load_profiles.items():
if not profile_name:
continue
gpu_idx = key_to_idx.get(gpu_key)
if gpu_idx is None:
log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key)
continue
log.info("Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key)
apply_with_retry(gpu_idx, profile_name, cfg)
shutdown_nvml()
+93
View File
@@ -0,0 +1,93 @@
"""Native profile storage and schema."""
import json
import os
import glob
from dataclasses import dataclass, asdict
from typing import Dict, Optional, List
@dataclass
class ProfileData:
name: str
gpu_name: str
curve_deltas: Dict[str, int] # { "index": delta_khz }
mem_offset_mhz: Optional[int] = None
power_limit_w: Optional[int] = None
def save_profile(profile_dir: str, data: ProfileData) -> str:
"""Save profile to JSON, sanitising the filename."""
os.makedirs(profile_dir, exist_ok=True)
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")
with open(filepath, "w", encoding="utf-8") as f:
json.dump(asdict(data), f, indent=2)
return filepath
def load_profile(filepath: str) -> ProfileData:
"""Load profile from JSON."""
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
# 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 e:
# log warning ideally, but swallowing for robustness
pass
# 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
+72
View File
@@ -0,0 +1,72 @@
"""Centralized safety validation for all write operations.
Every write path calls validate_write() before touching hardware.
"""
from .nvapi.constants import CT_POINTS
def check_negative_freq_warnings(
point_deltas: dict[int, int],
vfp_freqs_khz: list[int],
current_offsets_khz: list[int] | None = None,
) -> list[str]:
"""Return warnings for deltas that would produce negative effective frequency.
GetVFPCurve returns the *current effective* frequencies (already reflecting
any applied boost delta). The true hardware base for each point is therefore:
true_base = vfp_freq - current_offset
The new effective with the proposed delta:
new_effective = true_base + new_delta = vfp_freq + (new_delta - current_offset)
The driver clamps results, but users should be informed when their delta
would push a point's effective frequency below zero.
"""
warnings = []
for point, new_delta_khz in point_deltas.items():
if point < 0 or point >= len(vfp_freqs_khz):
continue
vfp_freq = vfp_freqs_khz[point]
if vfp_freq == 0:
continue # unused / placeholder point
current_delta = (
current_offsets_khz[point]
if current_offsets_khz and point < len(current_offsets_khz)
else 0
)
new_effective = vfp_freq + (new_delta_khz - current_delta)
if new_effective < 0:
warnings.append(
f"Point {point}: delta {new_delta_khz / 1000:+.0f} MHz would produce "
f"effective frequency {new_effective / 1000:.0f} MHz — driver will clamp to 0."
)
return warnings
def validate_write(
point_deltas: dict[int, int],
max_delta_khz: int,
) -> list[str]:
"""Validate a proposed write request.
Args:
point_deltas: {point_index: delta_kHz}
max_delta_khz: absolute delta limit (e.g. 300_000 for ±300 MHz)
Returns a list of error message strings. Empty list means the request is safe.
"""
errors = []
for point, delta_khz in point_deltas.items():
if point < 0 or point >= CT_POINTS:
errors.append(f"Point {point} out of range (0–{CT_POINTS - 1})")
continue
if abs(delta_khz) > max_delta_khz:
errors.append(
f"Delta {delta_khz / 1000:+.0f} MHz for point {point} exceeds "
f"safety limit of ±{max_delta_khz / 1000:.0f} MHz. "
"Use --max-delta to raise the limit if needed."
)
return errors
+1245
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "nvcurve"
version = "0.5.0"
description = "Linux GPU V/F curve editor for NVIDIA GPUs"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"nvidia-ml-py>=12.0",
"pydantic>=2.0",
"httpx>=0.27",
]
[project.scripts]
nvcurve = "nvcurve.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["nvcurve"]
[tool.hatch.build.targets.wheel.force-include]
"frontend/dist" = "nvcurve/frontend/dist"
[tool.hatch.build.targets.sdist]
include = [
"/nvcurve",
"/frontend/dist",
"/README.md",
]
File diff suppressed because it is too large. Load diff
Generated
+531
View File
@@ -0,0 +1,531 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.135.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "nvcurve"
version = "0.5.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "nvidia-ml-py" },
{ name = "pydantic" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "nvidia-ml-py", specifier = ">=12.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
]
[[package]]
name = "nvidia-ml-py"
version = "13.590.48"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/a0/f4fc18cf72f06821a9a665085435b901449986855519d5b3843532db35e9/nvidia_ml_py-13.590.48.tar.gz", hash = "sha256:8184d1be52914ac7f0991cd1c0d946c65dc88a840c754cd12c274b77b88760dd", size = 49732, upload-time = "2026-01-22T01:14:56.456Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/72/fb2af0d259a651affdce65fd6a495f0e07a685a0136baf585c5065204ee7/nvidia_ml_py-13.590.48-py3-none-any.whl", hash = "sha256:fd43d30ee9cd0b7940f5f9f9220b68d42722975e3992b6c21d14144c48760e43", size = 50680, upload-time = "2026-01-22T01:14:55.281Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "starlette"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]