nvcurve with some fixes and better limits
This commit is contained in:
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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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`.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+4877
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+3069
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
"""nvcurve — Linux GPU V/F curve editor for NVIDIA GPUs."""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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})")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Make profiles a package
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user