فهرست منبع

Added k profile management

Martin Ziegler 9 ماه پیش
والد
کامیت
5548b05f27

+ 53 - 0
README.md

@@ -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 - 0
backend/app/api/routes/kprofiles.py

@@ -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
backend/app/main.py

@@ -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 - 0
backend/app/models/printer.py

@@ -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 - 0
backend/app/schemas/kprofile.py

@@ -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 - 0
backend/app/schemas/printer.py

@@ -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 - 0
backend/app/services/bambu_mqtt.py

@@ -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 - 0
backend/app/services/printer_manager.py

@@ -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
frontend/src/App.tsx

@@ -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 - 0
frontend/src/api/client.ts

@@ -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 - 0
frontend/src/components/KProfilesView.tsx

@@ -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
frontend/src/components/Layout.tsx

@@ -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' },
 ];
 

+ 92 - 50
frontend/src/pages/CloudProfilesPage.tsx → frontend/src/pages/ProfilesPage.tsx

@@ -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>
   );
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-B9G6rzsh.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-BzGdgzuX.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-MvaVdELS.js


+ 2 - 2
static/index.html

@@ -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>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است