Added k profile management

This commit is contained in:
Martin Ziegler
2025-11-30 15:31:55 +01:00
parent 28a5e786e9
commit 5548b05f27
17 changed files with 1841 additions and 454 deletions
+53
View File
@@ -73,6 +73,12 @@ v∆v
- Multi-color support with color swatches
- **Failure Analysis** - Document failed prints with notes and photos
- **Project Page Editor** - View and edit embedded MakerWorld project pages with images, descriptions, and designer info
- **K-Profiles (Pressure Advance)** - Manage pressure advance settings directly on your printers
- View, edit, add, and delete K-profiles per printer
- Filter by nozzle size (0.2, 0.4, 0.6, 0.8mm) and flow type (High Flow, Standard)
- Search by profile name or filament
- Dual-nozzle support for H2 series (auto-detected from MQTT)
- Left/Right extruder column layout for dual-nozzle printers
- **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
- **File Manager** - Browse and manage files on your printer's SD card
- **Re-print** - Send archived prints back to any connected printer
@@ -430,6 +436,52 @@ When a scheduled print is ready to start:
4. Click "Edit" to modify the project page metadata
5. Changes are saved directly to the 3MF file
### K-Profiles (Pressure Advance)
K-profiles store pressure advance (Linear Advance) settings for different filament and nozzle combinations. Bambusy lets you view and manage these settings directly on your printers.
#### Viewing K-Profiles
1. Go to **Settings** > **K-Profiles**
2. Select a connected printer from the dropdown
3. Choose a nozzle size (0.2, 0.4, 0.6, or 0.8mm)
4. Profiles are displayed with:
- K-value (pressure advance factor)
- Profile name and filament
- Flow type (HF = High Flow, S = Standard)
#### Dual-Nozzle Printers (H2 Series)
For dual-nozzle printers (H2D, H2C, H2S), Bambusy automatically detects the nozzle configuration and displays:
- **Left/Right columns** showing profiles for each extruder
- **Extruder filter** to show profiles for one extruder only
- **Extruder selector** when adding new profiles
The nozzle count is auto-detected from MQTT temperature data when the printer connects.
#### Editing K-Profiles
1. Click on any profile card to open the edit modal
2. Modify the K-value (typical ranges: 0.01-0.06 for PLA, 0.02-0.10 for PETG)
3. Click **Save** to update the profile on the printer
4. Click the trash icon to delete a profile (with confirmation)
#### Adding K-Profiles
1. Click **Add Profile** in the header
2. Select a filament from the dropdown (populated from existing profiles on the printer)
3. Choose flow type (High Flow or Standard) and nozzle size
4. For dual-nozzle printers, select Left or Right extruder
5. Enter the K-value and click **Save**
**Note:** Filaments must first be calibrated in Bambu Studio to appear in the dropdown. Bambusy reads the filament list from existing K-profiles on the printer.
#### Filtering and Search
- **Search**: Type to filter by profile name or filament ID
- **Extruder filter** (dual-nozzle only): Show All, Left Only, or Right Only
- **Flow type filter**: Show All, HF Only, or S Only
### Smart Plug Integration
Bambusy supports Tasmota-based smart plugs for automated power control. This is useful for:
@@ -646,6 +698,7 @@ To fix the printer's clock:
- [x] Energy monitoring and statistics
- [x] Print scheduling and queuing
- [x] Automatic finish photo capture
- [x] K-Profiles management (pressure advance)
- [ ] Maintenance tracker
- [ ] Notifications (email, push)
- [ ] Mobile-optimized UI
+148
View File
@@ -0,0 +1,148 @@
"""API routes for K-profile (pressure advance) management."""
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.models.printer import Printer
from backend.app.schemas.kprofile import (
KProfile,
KProfileCreate,
KProfileDelete,
KProfilesResponse,
)
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printers/{printer_id}/kprofiles", tags=["kprofiles"])
@router.get("/", response_model=KProfilesResponse)
async def get_kprofiles(
printer_id: int,
nozzle_diameter: str = "0.4",
db: AsyncSession = Depends(get_db),
):
"""Get K-profiles from a printer.
Args:
printer_id: ID of the printer
nozzle_diameter: Filter by nozzle diameter (default: "0.4")
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Get MQTT client for printer
client = printer_manager.get_client(printer_id)
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
# Request K-profiles from printer
profiles = await client.get_kprofiles(nozzle_diameter=nozzle_diameter)
# Convert from MQTT dataclass to Pydantic schema
return KProfilesResponse(
profiles=[
KProfile(
slot_id=p.slot_id,
extruder_id=p.extruder_id,
nozzle_id=p.nozzle_id,
nozzle_diameter=p.nozzle_diameter,
filament_id=p.filament_id,
name=p.name,
k_value=p.k_value,
n_coef=p.n_coef,
ams_id=p.ams_id,
tray_id=p.tray_id,
setting_id=p.setting_id,
)
for p in profiles
],
nozzle_diameter=nozzle_diameter,
)
@router.post("/", response_model=dict)
async def set_kprofile(
printer_id: int,
profile: KProfileCreate,
db: AsyncSession = Depends(get_db),
):
"""Create or update a K-profile on the printer.
Args:
printer_id: ID of the printer
profile: K-profile data to set
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Get MQTT client for printer
client = printer_manager.get_client(printer_id)
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
# Send the K-profile to printer
success = client.set_kprofile(
filament_id=profile.filament_id,
name=profile.name,
k_value=profile.k_value,
nozzle_diameter=profile.nozzle_diameter,
nozzle_id=profile.nozzle_id,
extruder_id=profile.extruder_id,
setting_id=profile.setting_id,
slot_id=profile.slot_id,
)
if not success:
raise HTTPException(500, "Failed to send K-profile command")
return {"success": True, "message": "K-profile set successfully"}
@router.delete("/", response_model=dict)
async def delete_kprofile(
printer_id: int,
profile: KProfileDelete,
db: AsyncSession = Depends(get_db),
):
"""Delete a K-profile from the printer.
Args:
printer_id: ID of the printer
profile: K-profile identification data for deletion
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Get MQTT client for printer
client = printer_manager.get_client(printer_id)
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
# Send the delete command to printer
success = client.delete_kprofile(
cali_idx=profile.slot_id,
filament_id=profile.filament_id,
nozzle_id=profile.nozzle_id,
nozzle_diameter=profile.nozzle_diameter,
extruder_id=profile.extruder_id,
)
if not success:
raise HTTPException(500, "Failed to send K-profile delete command")
return {"success": True, "message": "K-profile deleted successfully"}
+20 -1
View File
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
from backend.app.core.database import init_db, async_session
from sqlalchemy import select, or_
from backend.app.core.websocket import ws_manager
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles
from backend.app.api.routes import settings as settings_routes
from backend.app.services.printer_manager import (
printer_manager,
@@ -96,6 +96,7 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int):
_last_status_broadcast: dict[int, str] = {}
_nozzle_count_updated: set[int] = set() # Track printers where we've updated nozzle_count
async def on_printer_status_change(printer_id: int, state: PrinterState):
"""Handle printer status changes - broadcast via WebSocket."""
@@ -107,6 +108,23 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
nozzle_2_temp = round(temps.get("nozzle_2", 0)) if "nozzle_2" in temps else ""
chamber_temp = round(temps.get("chamber", 0)) if "chamber" in temps else ""
# Auto-detect dual-nozzle printers from MQTT temperature data
if "nozzle_2" in temps and printer_id not in _nozzle_count_updated:
_nozzle_count_updated.add(printer_id)
# Update nozzle_count in database
async with async_session() as db:
from backend.app.models.printer import Printer
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
if printer and printer.nozzle_count != 2:
printer.nozzle_count = 2
await db.commit()
logging.getLogger(__name__).info(
f"Auto-detected dual-nozzle printer {printer_id}, updated nozzle_count=2"
)
status_key = (
f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}"
@@ -736,6 +754,7 @@ app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
app.include_router(cloud.router, prefix=app_settings.api_prefix)
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
app.include_router(websocket.router, prefix=app_settings.api_prefix)
+1
View File
@@ -14,6 +14,7 @@ class Printer(Base):
ip_address: Mapped[str] = mapped_column(String(45))
access_code: Mapped[str] = mapped_column(String(20))
model: Mapped[str | None] = mapped_column(String(50))
nozzle_count: Mapped[int] = mapped_column(default=1) # 1 or 2, auto-detected from MQTT
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
+52
View File
@@ -0,0 +1,52 @@
"""Pydantic schemas for K-profile (pressure advance) management."""
from pydantic import BaseModel
class KProfile(BaseModel):
"""A pressure advance (K) calibration profile stored on the printer."""
slot_id: int # Storage slot on printer (limited capacity ~20 slots)
extruder_id: int = 0 # 0 or 1 for dual nozzle printers
nozzle_id: str # e.g., "HS00-0.4" (hardened steel 0.4mm)
nozzle_diameter: str # e.g., "0.4"
filament_id: str # Bambu filament identifier
name: str # User-defined name for the profile
k_value: str # Pressure advance coefficient as string, e.g., "0.020000"
n_coef: str = "0.000000" # N coefficient (usually 0)
ams_id: int = 0 # AMS unit ID
tray_id: int = -1 # AMS tray ID (-1 if not linked)
setting_id: str | None = None # Unique setting identifier
class KProfileCreate(BaseModel):
"""Schema for creating/updating a K-profile."""
slot_id: int = 0 # Storage slot, 0 for new profiles
extruder_id: int = 0
nozzle_id: str
nozzle_diameter: str
filament_id: str
name: str
k_value: str
n_coef: str = "0.000000"
ams_id: int = 0
tray_id: int = -1
setting_id: str | None = None
class KProfilesResponse(BaseModel):
"""Response containing K-profiles from a printer."""
profiles: list[KProfile]
nozzle_diameter: str # Current nozzle filter
class KProfileDelete(BaseModel):
"""Schema for deleting a K-profile."""
slot_id: int # cali_idx - calibration index to delete
extruder_id: int = 0
nozzle_id: str # e.g., "HH00-0.4"
nozzle_diameter: str # e.g., "0.4"
filament_id: str # Bambu filament identifier
+1
View File
@@ -27,6 +27,7 @@ class PrinterUpdate(BaseModel):
class PrinterResponse(PrinterBase):
id: int
is_active: bool
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
created_at: datetime
updated_at: datetime
+224
View File
@@ -31,6 +31,22 @@ class HMSError:
message: str = ""
@dataclass
class KProfile:
"""Pressure advance (K) calibration profile from printer."""
slot_id: int
extruder_id: int
nozzle_id: str
nozzle_diameter: str
filament_id: str
name: str
k_value: str
n_coef: str = "0.000000"
ams_id: int = 0
tray_id: int = -1
setting_id: str | None = None
@dataclass
class PrinterState:
connected: bool = False
@@ -46,6 +62,7 @@ class PrinterState:
gcode_file: str | None = None
subtask_id: str | None = None
hms_errors: list = field(default_factory=list) # List of HMSError
kprofiles: list = field(default_factory=list) # List of KProfile
class BambuMQTTClient:
@@ -80,6 +97,11 @@ class BambuMQTTClient:
self._logging_enabled: bool = False
self._last_message_time: float = 0.0 # Track when we last received a message
# K-profile command tracking
self._sequence_id: int = 0
self._pending_kprofile_response: asyncio.Event | None = None
self._kprofile_response_data: list | None = None
@property
def topic_subscribe(self) -> str:
return f"device/{self.serial_number}/report"
@@ -140,6 +162,11 @@ class BambuMQTTClient:
f"[{self.serial_number}] Received gcode_state: {print_data.get('gcode_state')}, "
f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
)
# Check for K-profile response (extrusion_cali)
if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
self._handle_kprofile_response(print_data)
self._update_state(print_data)
def _update_state(self, data: dict):
@@ -437,3 +464,200 @@ class BambuMQTTClient:
def logging_enabled(self) -> bool:
"""Check if logging is enabled."""
return self._logging_enabled
def _handle_kprofile_response(self, data: dict):
"""Handle K-profile response from printer."""
filaments = data.get("filaments", [])
profiles = []
# Log first profile to see what fields the printer returns
if filaments and isinstance(filaments[0], dict):
logger.debug(f"[{self.serial_number}] Raw K-profile fields: {list(filaments[0].keys())}")
logger.debug(f"[{self.serial_number}] First K-profile: {filaments[0]}")
for i, f in enumerate(filaments):
if isinstance(f, dict):
try:
# cali_idx is the actual slot/calibration index from the printer
cali_idx = f.get("cali_idx", i)
profiles.append(KProfile(
slot_id=cali_idx,
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
))
except (ValueError, TypeError) as e:
logger.warning(f"Failed to parse K-profile: {e}")
self.state.kprofiles = profiles
self._kprofile_response_data = profiles
# Signal that we received the response
if self._pending_kprofile_response:
self._pending_kprofile_response.set()
logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0) -> list[KProfile]:
"""Request K-profiles from the printer.
Args:
nozzle_diameter: Filter by nozzle diameter (e.g., "0.4")
timeout: Timeout in seconds to wait for response
Returns:
List of KProfile objects
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot get K-profiles: not connected")
return []
# Set up response event
self._sequence_id += 1
self._pending_kprofile_response = asyncio.Event()
self._kprofile_response_data = None
# Send the command
command = {
"print": {
"command": "extrusion_cali_get",
"filament_id": "",
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
}
}
logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter}")
self._client.publish(self.topic_publish, json.dumps(command))
# Wait for response
try:
await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
return self._kprofile_response_data or []
except asyncio.TimeoutError:
logger.warning(f"[{self.serial_number}] Timeout waiting for K-profiles response")
return []
finally:
self._pending_kprofile_response = None
def set_kprofile(
self,
filament_id: str,
name: str,
k_value: str,
nozzle_diameter: str = "0.4",
nozzle_id: str = "HS00-0.4",
extruder_id: int = 0,
setting_id: str | None = None,
slot_id: int = 0,
) -> bool:
"""Set/update a K-profile on the printer.
Args:
filament_id: Bambu filament identifier
name: Profile name
k_value: Pressure advance value (e.g., "0.020000")
nozzle_diameter: Nozzle diameter (e.g., "0.4")
nozzle_id: Nozzle identifier (e.g., "HS00-0.4")
extruder_id: Extruder ID (0 or 1 for dual nozzle)
setting_id: Existing setting ID for updates, None for new
slot_id: Calibration index (cali_idx) for the profile
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot set K-profile: not connected")
return False
self._sequence_id += 1
# Build the filament entry - printer uses cali_idx for profile identification
# For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
cali_idx = -1 if slot_id == 0 else slot_id
# Generate a setting_id for new profiles (required by printer)
# Format: "PF" + 17 random digits
import random
if not setting_id and slot_id == 0:
setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}"
filament_entry = {
"ams_id": 0,
"cali_idx": cali_idx,
"extruder_id": extruder_id,
"filament_id": filament_id,
"k_value": k_value,
"n_coef": "0.000000",
"name": name,
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"setting_id": setting_id, # Always include setting_id
"tray_id": -1,
}
command = {
"print": {
"command": "extrusion_cali_set",
"filaments": [filament_entry],
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0})")
logger.debug(f"[{self.serial_number}] K-profile command: {command_json}")
self._client.publish(self.topic_publish, command_json)
return True
def delete_kprofile(
self,
cali_idx: int,
filament_id: str,
nozzle_id: str,
nozzle_diameter: str = "0.4",
extruder_id: int = 0,
) -> bool:
"""Delete a K-profile from the printer.
Args:
cali_idx: The calibration index (slot_id) of the profile to delete
filament_id: Bambu filament identifier
nozzle_id: Nozzle identifier (e.g., "HH00-0.4")
nozzle_diameter: Nozzle diameter (e.g., "0.4")
extruder_id: Extruder ID (0 or 1 for dual nozzle)
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot delete K-profile: not connected")
return False
self._sequence_id += 1
command = {
"print": {
"command": "extrusion_cali_del",
"sequence_id": str(self._sequence_id),
"extruder_id": extruder_id,
"nozzle_id": nozzle_id,
"filament_id": filament_id,
"cali_idx": cali_idx,
"nozzle_diameter": nozzle_diameter,
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}")
logger.debug(f"[{self.serial_number}] K-profile delete command: {command_json}")
self._client.publish(self.topic_publish, command_json)
return True
+4
View File
@@ -112,6 +112,10 @@ class PrinterManager:
return self._clients[printer_id].state.connected
return False
def get_client(self, printer_id: int) -> BambuMQTTClient | None:
"""Get the MQTT client for a printer."""
return self._clients.get(printer_id)
def mark_printer_offline(self, printer_id: int):
"""Mark a printer as offline and trigger status callback.
+2 -2
View File
@@ -6,7 +6,7 @@ import { ArchivesPage } from './pages/ArchivesPage';
import { QueuePage } from './pages/QueuePage';
import { StatsPage } from './pages/StatsPage';
import { SettingsPage } from './pages/SettingsPage';
import { CloudProfilesPage } from './pages/CloudProfilesPage';
import { ProfilesPage } from './pages/ProfilesPage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -38,7 +38,7 @@ function App() {
<Route path="archives" element={<ArchivesPage />} />
<Route path="queue" element={<QueuePage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="cloud" element={<CloudProfilesPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
+57
View File
@@ -28,6 +28,7 @@ export interface Printer {
ip_address: string;
access_code: string;
model: string | null;
nozzle_count: number; // 1 or 2, auto-detected from MQTT
is_active: boolean;
auto_archive: boolean;
created_at: string;
@@ -311,6 +312,48 @@ export interface MQTTLogsResponse {
logs: MQTTLogEntry[];
}
// K-Profile types
export interface KProfile {
slot_id: number;
extruder_id: number;
nozzle_id: string;
nozzle_diameter: string;
filament_id: string;
name: string;
k_value: string;
n_coef: string;
ams_id: number;
tray_id: number;
setting_id: string | null;
}
export interface KProfileCreate {
slot_id?: number; // Storage slot, 0 for new profiles
extruder_id?: number;
nozzle_id: string;
nozzle_diameter: string;
filament_id: string;
name: string;
k_value: string;
n_coef?: string;
ams_id?: number;
tray_id?: number;
setting_id?: string | null;
}
export interface KProfileDelete {
slot_id: number; // cali_idx - calibration index to delete
extruder_id: number;
nozzle_id: string; // e.g., "HH00-0.4"
nozzle_diameter: string; // e.g., "0.4"
filament_id: string; // Bambu filament identifier
}
export interface KProfilesResponse {
profiles: KProfile[];
nozzle_diameter: string;
}
// API functions
export const api = {
// Printers
@@ -642,4 +685,18 @@ export const api = {
request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
stopQueueItem: (id: number) =>
request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
// K-Profiles
getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
request<KProfilesResponse>(`/printers/${printerId}/kprofiles/?nozzle_diameter=${nozzleDiameter}`),
setKProfile: (printerId: number, profile: KProfileCreate) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {
method: 'POST',
body: JSON.stringify(profile),
}),
deleteKProfile: (printerId: number, profile: KProfileDelete) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {
method: 'DELETE',
body: JSON.stringify(profile),
}),
};
+786
View File
@@ -0,0 +1,786 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Gauge,
Loader2,
RefreshCw,
Printer,
Plus,
X,
AlertCircle,
WifiOff,
Trash2,
Search,
} from 'lucide-react';
import { api } from '../api/client';
import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface KProfileCardProps {
profile: KProfile;
onEdit: () => void;
}
// Truncate to 3 decimal places (like Bambu Studio) instead of rounding
const truncateK = (value: string) => {
const num = parseFloat(value);
return (Math.trunc(num * 1000) / 1000).toFixed(3);
};
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
const getFlowTypeLabel = (nozzleId: string) => {
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
return 'S'; // Standard Flow (default)
};
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
const getNozzleTypePrefix = (nozzleId: string) => {
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
return match ? match[1] : 'HH00';
};
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
const extractFilamentName = (profileName: string) => {
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
// Remove common prefixes - check both underscore and space separators
const prefixes = [
'High Flow_', 'High Flow ', // underscore or space
'Standard_', 'Standard ',
'HF_', 'HF ',
'S_', 'S ',
];
for (const prefix of prefixes) {
if (profileName.startsWith(prefix)) {
return profileName.slice(prefix.length);
}
}
// If no prefix found, check for underscore separator
const underscoreIdx = profileName.indexOf('_');
if (underscoreIdx > 0) {
return profileName.slice(underscoreIdx + 1);
}
return profileName;
};
function KProfileCard({ profile, onEdit }: KProfileCardProps) {
const flowType = getFlowTypeLabel(profile.nozzle_id);
const diameter = profile.nozzle_diameter;
const profileName = profile.name || 'Unnamed';
// Extract filament name from profile name (e.g., "High Flow_eSUN ABS+" -> "eSUN ABS+")
const filamentName = extractFilamentName(profile.name || '');
return (
<button
onClick={onEdit}
className="w-full text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
{truncateK(profile.k_value)}
</span>
<span className="text-white text-sm truncate flex-1" title={profileName}>
{profileName}
</span>
<span className="text-xs text-bambu-gray whitespace-nowrap">
{flowType} {diameter}
</span>
</div>
<div className="text-xs text-bambu-gray mt-0.5 truncate" title={`Filament: ${filamentName}`}>
Filament: {filamentName || profile.filament_id}
</div>
</button>
);
}
interface KProfileModalProps {
profile?: KProfile;
printerId: number;
nozzleDiameter: string;
existingProfiles?: KProfile[]; // Existing profiles for filament selection
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
onClose: () => void;
onSave: () => void;
}
function KProfileModal({
profile,
printerId,
nozzleDiameter,
existingProfiles = [],
isDualNozzle = false,
onClose,
onSave,
}: KProfileModalProps) {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [name, setName] = useState(profile?.name || '');
const [kValue, setKValue] = useState(
profile?.k_value ? truncateK(profile.k_value) : '0.020'
);
const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
// Split nozzle into type and diameter
const [nozzleType, setNozzleType] = useState(
profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
);
const [modalDiameter, setModalDiameter] = useState(
profile?.nozzle_diameter || nozzleDiameter
);
const [extruderId, setExtruderId] = useState(profile?.extruder_id || 0);
// Extract unique filaments from existing K-profiles on the printer
// These have valid filament_ids that the printer recognizes
const knownFilaments = React.useMemo(() => {
const filamentMap = new Map<string, { id: string; name: string }>();
for (const p of existingProfiles) {
if (p.filament_id && !filamentMap.has(p.filament_id)) {
const filamentName = extractFilamentName(p.name || '');
filamentMap.set(p.filament_id, {
id: p.filament_id,
name: filamentName || p.filament_id,
});
}
}
return Array.from(filamentMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [existingProfiles]);
const saveMutation = useMutation({
mutationFn: (data: KProfileCreate) => {
console.log('[KProfile] Calling API...');
return api.setKProfile(printerId, data);
},
onSuccess: (result) => {
console.log('[KProfile] Save success:', result);
showToast('K-profile saved');
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
onSave();
},
onError: (error: Error) => {
console.error('[KProfile] Save error:', error);
showToast(error.message, 'error');
},
});
const deleteMutation = useMutation({
mutationFn: (data: KProfileDelete) => {
console.log('[KProfile] Deleting profile...');
return api.deleteKProfile(printerId, data);
},
onSuccess: (result) => {
console.log('[KProfile] Delete success:', result);
showToast('K-profile deleted');
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
onClose();
},
onError: (error: Error) => {
console.error('[KProfile] Delete error:', error);
showToast(error.message, 'error');
},
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleDelete = () => {
if (!profile) return;
deleteMutation.mutate({
slot_id: profile.slot_id,
extruder_id: profile.extruder_id,
nozzle_id: profile.nozzle_id,
nozzle_diameter: profile.nozzle_diameter,
filament_id: profile.filament_id,
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Format k_value to 6 decimal places for Bambu protocol
const formattedKValue = parseFloat(kValue).toFixed(6);
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
const nozzleId = `${nozzleType}-${modalDiameter}`;
// Use the name from the form - it's auto-populated when filament is selected
// but can be edited by the user
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: profile?.setting_id,
slot_id: profile?.slot_id ?? 0,
};
console.log('[KProfile] Saving profile:', payload);
saveMutation.mutate(payload);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardContent className="p-0">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-xl font-semibold text-white">
{profile ? 'Edit K-Profile' : 'Add K-Profile'}
</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Profile Name - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Profile Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
placeholder="My PLA Profile"
required
/>
</div>
{/* K-Value - always editable */}
<div>
<label className="block text-sm text-bambu-gray mb-1">K-Value</label>
<input
type="text"
inputMode="decimal"
value={kValue}
onChange={(e) => {
// Allow typing any decimal value
const val = e.target.value;
if (val === '' || /^\d*\.?\d*$/.test(val)) {
setKValue(val);
}
}}
onBlur={(e) => {
// Format to 3 decimal places on blur
const num = parseFloat(e.target.value);
if (!isNaN(num)) {
setKValue((Math.trunc(num * 1000) / 1000).toFixed(3));
}
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono"
placeholder="0.020"
required
/>
<p className="text-xs text-bambu-gray mt-1">
Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG
</p>
</div>
{/* Filament - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Filament</label>
<select
value={filamentId}
onChange={(e) => {
const newFilamentId = e.target.value;
setFilamentId(newFilamentId);
// Auto-generate profile name when filament is selected (for new profiles)
// Only auto-generate if name is empty - don't overwrite user input
if (!profile && newFilamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
if (selectedFilament) {
const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
}}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
required
>
<option value="">Select filament...</option>
{/* Show current filament when editing */}
{profile?.filament_id && (
<option key={profile.filament_id} value={profile.filament_id}>
{extractFilamentName(profile.name || profile.filament_id)}
</option>
)}
{/* Show known filaments from existing K-profiles (for new profiles) */}
{!profile && knownFilaments.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
{!profile && knownFilaments.length === 0 && (
<p className="text-xs text-bambu-gray mt-1">
No filaments found. Create a K-profile in Bambu Studio first.
</p>
)}
</div>
{/* Flow Type and Nozzle Size - read-only when editing */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">Flow Type</label>
<select
value={nozzleType}
onChange={(e) => {
const newNozzleType = e.target.value;
setNozzleType(newNozzleType);
// Update profile name when flow type changes (for new profiles)
// Only auto-generate if name is empty - don't overwrite user input
if (!profile && filamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
if (selectedFilament) {
const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
}}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="HH00">High Flow</option>
<option value="HS00">Standard</option>
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Nozzle Size</label>
<select
value={modalDiameter}
onChange={(e) => setModalDiameter(e.target.value)}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="0.2">0.2mm</option>
<option value="0.4">0.4mm</option>
<option value="0.6">0.6mm</option>
<option value="0.8">0.8mm</option>
</select>
</div>
</div>
{/* Extruder - only show for dual-nozzle printers, read-only when editing */}
{isDualNozzle && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Extruder</label>
<select
value={extruderId}
onChange={(e) => setExtruderId(parseInt(e.target.value))}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value={1}>Left</option>
<option value={0}>Right</option>
</select>
</div>
)}
<div className="flex gap-2 pt-4">
{profile && (
<Button
type="button"
variant="secondary"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending}
className="text-red-500 hover:bg-red-500/10"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
)}
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={saveMutation.isPending}
className="flex-1"
>
{saveMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Gauge className="w-4 h-4" />
)}
Save
</Button>
</div>
</form>
</CardContent>
</Card>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[60]">
<Card className="w-full max-w-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Delete Profile</h3>
<p className="text-sm text-bambu-gray">This cannot be undone</p>
</div>
</div>
<p className="text-bambu-gray mb-6">
Are you sure you want to delete <span className="text-white font-medium">"{profile?.name}"</span> from the printer?
</p>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => setShowDeleteConfirm(false)}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={() => {
setShowDeleteConfirm(false);
handleDelete();
}}
disabled={deleteMutation.isPending}
className="flex-1 bg-red-500 hover:bg-red-600 text-white"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
type ExtruderFilter = 'all' | 'left' | 'right';
type FlowTypeFilter = 'all' | 'hf' | 's';
export function KProfilesView() {
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
const [nozzleDiameter, setNozzleDiameter] = useState('0.4');
const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
// Get available printers
const { data: printers, isLoading: printersLoading } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Get K-profiles for selected printer
const {
data: kprofiles,
isLoading: kprofilesLoading,
error: kprofilesError,
refetch: refetchProfiles,
} = useQuery({
queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
queryFn: () => api.getKProfiles(selectedPrinter!, nozzleDiameter),
enabled: !!selectedPrinter,
retry: false,
});
// Check if error is due to printer not being connected
const isOfflineError = kprofilesError?.message?.includes('not connected');
// Filter profiles based on search query, extruder filter, and flow type
const filteredProfiles = React.useMemo(() => {
if (!kprofiles?.profiles) return [];
return kprofiles.profiles.filter((p) => {
// Search filter - match name or filament_id (case-insensitive)
const query = searchQuery.toLowerCase();
const matchesSearch =
!query ||
p.name.toLowerCase().includes(query) ||
p.filament_id.toLowerCase().includes(query);
// Extruder filter
const matchesExtruder =
extruderFilter === 'all' ||
(extruderFilter === 'left' && p.extruder_id === 1) ||
(extruderFilter === 'right' && p.extruder_id === 0);
// Flow type filter (HH = High Flow, HS = Standard)
const matchesFlowType =
flowTypeFilter === 'all' ||
(flowTypeFilter === 'hf' && p.nozzle_id.startsWith('HH')) ||
(flowTypeFilter === 's' && p.nozzle_id.startsWith('HS'));
return matchesSearch && matchesExtruder && matchesFlowType;
});
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter]);
// Auto-select first connected printer
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
if (!selectedPrinter && connectedPrinters.length > 0) {
setSelectedPrinter(connectedPrinters[0].id);
}
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
if (printersLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
if (!printers || printers.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center">
<AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Printers Configured</h3>
<p className="text-bambu-gray">
Add a printer in Settings to manage K-profiles
</p>
</CardContent>
</Card>
);
}
if (connectedPrinters.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center">
<Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Active Printers</h3>
<p className="text-bambu-gray">
Enable a printer connection to view its K-profiles
</p>
</CardContent>
</Card>
);
}
return (
<>
{/* Printer & Nozzle Selector */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-48">
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
<select
value={selectedPrinter || ''}
onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{connectedPrinters.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
</div>
<div className="w-32">
<label className="block text-sm text-bambu-gray mb-1">Nozzle</label>
<select
value={nozzleDiameter}
onChange={(e) => setNozzleDiameter(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="0.2">0.2mm</option>
<option value="0.4">0.4mm</option>
<option value="0.6">0.6mm</option>
<option value="0.8">0.8mm</option>
</select>
</div>
<div className="flex items-end gap-2">
<Button
variant="secondary"
onClick={() => refetchProfiles()}
disabled={kprofilesLoading}
>
<RefreshCw className={`w-4 h-4 ${kprofilesLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Add Profile
</Button>
</div>
</div>
{/* Search & Filter Row */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-48 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by name or filament..."
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
{isDualNozzle && (
<div className="w-36">
<select
value={extruderFilter}
onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">All Extruders</option>
<option value="left">Left Only</option>
<option value="right">Right Only</option>
</select>
</div>
)}
<div className="w-32">
<select
value={flowTypeFilter}
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">All Flow</option>
<option value="hf">HF Only</option>
<option value="s">S Only</option>
</select>
</div>
</div>
{/* K-Profiles Grid */}
{kprofilesLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : isOfflineError ? (
<Card>
<CardContent className="py-12 text-center">
<WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Printer Offline</h3>
<p className="text-bambu-gray mb-4">
The selected printer is not connected. Power it on to view K-profiles.
</p>
<Button variant="secondary" onClick={() => refetchProfiles()}>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
</CardContent>
</Card>
) : filteredProfiles.length > 0 ? (
isDualNozzle ? (
// Dual-nozzle: show Left/Right columns
<div className="grid grid-cols-2 gap-4">
{/* Left Extruder (extruder_id 1 on Bambu) */}
<div>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Left Extruder</h3>
<div className="space-y-1">
{filteredProfiles
.filter((p) => p.extruder_id === 1)
.map((profile) => (
<KProfileCard
key={profile.slot_id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
/>
))}
</div>
</div>
{/* Right Extruder (extruder_id 0 on Bambu) */}
<div>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Right Extruder</h3>
<div className="space-y-1">
{filteredProfiles
.filter((p) => p.extruder_id === 0)
.map((profile) => (
<KProfileCard
key={profile.slot_id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
/>
))}
</div>
</div>
</div>
) : (
// Single-nozzle: show all profiles in one list
<div className="space-y-1">
{filteredProfiles.map((profile) => (
<KProfileCard
key={profile.slot_id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
/>
))}
</div>
)
) : searchQuery || extruderFilter !== 'all' || flowTypeFilter !== 'all' ? (
<Card>
<CardContent className="py-12 text-center">
<Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Matching Profiles</h3>
<p className="text-bambu-gray">
No profiles match your search criteria
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-12 text-center">
<Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No K-Profiles</h3>
<p className="text-bambu-gray mb-4">
No pressure advance profiles found for {nozzleDiameter}mm nozzle
</p>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Create First Profile
</Button>
</CardContent>
</Card>
)}
{/* Edit Modal */}
{editingProfile && selectedPrinter && (
<KProfileModal
profile={editingProfile}
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onClose={() => setEditingProfile(null)}
onSave={() => setEditingProfile(null)}
/>
)}
{/* Add Modal */}
{showAddModal && selectedPrinter && (
<KProfileModal
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onClose={() => setShowAddModal(false)}
onSave={() => setShowAddModal(false)}
/>
)}
</>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'archives', to: '/archives', icon: Archive, label: 'Archives' },
{ id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
{ id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
{ id: 'cloud', to: '/cloud', icon: Cloud, label: 'Cloud Profiles' },
{ id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
{ id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
];
@@ -13,12 +13,16 @@ import {
X,
Key,
RefreshCw,
Gauge,
} from 'lucide-react';
import { api } from '../api/client';
import type { SlicerSetting, SlicerSettingsResponse } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
import { KProfilesView } from '../components/KProfilesView';
type ProfileTab = 'cloud' | 'kprofiles';
type LoginStep = 'email' | 'code' | 'token';
@@ -398,9 +402,10 @@ function ProfilesView({ settings }: { settings: SlicerSettingsResponse }) {
);
}
export function CloudProfilesPage() {
export function ProfilesPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [activeTab, setActiveTab] = useState<ProfileTab>('kprofiles');
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['cloudStatus'],
@@ -438,58 +443,95 @@ export function CloudProfilesPage() {
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Cloud className="w-6 h-6 text-bambu-green" />
Cloud Profiles
</h1>
<p className="text-bambu-gray">
{status?.is_authenticated
? `Connected as ${status.email}`
: 'Manage your Bambu Cloud slicer presets'}
</p>
</div>
{status?.is_authenticated && (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => refetchSettings()}
disabled={settingsLoading}
>
<RefreshCw className={`w-4 h-4 ${settingsLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button
variant="secondary"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
)}
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Profiles</h1>
<p className="text-bambu-gray">
Manage your slicer presets and pressure advance calibrations
</p>
</div>
{!status?.is_authenticated ? (
<LoginForm onSuccess={handleLoginSuccess} />
) : settingsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : settings ? (
<ProfilesView settings={settings} />
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-bambu-gray">Failed to load profiles</p>
<Button className="mt-4" onClick={() => refetchSettings()}>
Retry
</Button>
</CardContent>
</Card>
{/* Tab Navigation */}
<div className="flex border-b border-bambu-dark-tertiary mb-6">
<button
onClick={() => setActiveTab('cloud')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === 'cloud'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-white border-transparent'
}`}
>
<Cloud className="w-4 h-4" />
Cloud Profiles
</button>
<button
onClick={() => setActiveTab('kprofiles')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === 'kprofiles'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-white border-transparent'
}`}
>
<Gauge className="w-4 h-4" />
K-Profiles
</button>
</div>
{/* Cloud Profiles Tab */}
{activeTab === 'cloud' && (
<>
{/* Cloud Status Header */}
<div className="mb-6 flex items-center justify-between">
<p className="text-bambu-gray">
{status?.is_authenticated
? `Connected as ${status.email}`
: 'Connect to Bambu Cloud to access your slicer presets'}
</p>
{status?.is_authenticated && (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => refetchSettings()}
disabled={settingsLoading}
>
<RefreshCw className={`w-4 h-4 ${settingsLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button
variant="secondary"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
)}
</div>
{!status?.is_authenticated ? (
<LoginForm onSuccess={handleLoginSuccess} />
) : settingsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : settings ? (
<ProfilesView settings={settings} />
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-bambu-gray">Failed to load profiles</p>
<Button className="mt-4" onClick={() => refetchSettings()}>
Retry
</Button>
</CardContent>
</Card>
)}
</>
)}
{/* K-Profiles Tab */}
{activeTab === 'kprofiles' && <KProfilesView />}
</div>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-DUMaPDwX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BzGdgzuX.css">
<script type="module" crossorigin src="/assets/index-MvaVdELS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B9G6rzsh.css">
</head>
<body>
<div id="root"></div>