Explorar o código

Added k profile management

Martin Ziegler hai 9 meses
pai
achega
5548b05f27

+ 53 - 0
README.md

@@ -73,6 +73,12 @@ v∆v
   - Multi-color support with color swatches
   - Multi-color support with color swatches
 - **Failure Analysis** - Document failed prints with notes and photos
 - **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
 - **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
 - **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
 - **File Manager** - Browse and manage files on your printer's SD card
 - **File Manager** - Browse and manage files on your printer's SD card
 - **Re-print** - Send archived prints back to any connected printer
 - **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
 4. Click "Edit" to modify the project page metadata
 5. Changes are saved directly to the 3MF file
 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
 ### Smart Plug Integration
 
 
 Bambusy supports Tasmota-based smart plugs for automated power control. This is useful for:
 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] Energy monitoring and statistics
 - [x] Print scheduling and queuing
 - [x] Print scheduling and queuing
 - [x] Automatic finish photo capture
 - [x] Automatic finish photo capture
+- [x] K-Profiles management (pressure advance)
 - [ ] Maintenance tracker
 - [ ] Maintenance tracker
 - [ ] Notifications (email, push)
 - [ ] Notifications (email, push)
 - [ ] Mobile-optimized UI
 - [ ] 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 backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
 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.api.routes import settings as settings_routes
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     printer_manager,
     printer_manager,
@@ -96,6 +96,7 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int):
 
 
 
 
 _last_status_broadcast: dict[int, str] = {}
 _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):
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
     """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 ""
     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 ""
     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 = (
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}"
         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(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.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(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)
 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))
     ip_address: Mapped[str] = mapped_column(String(45))
     access_code: Mapped[str] = mapped_column(String(20))
     access_code: Mapped[str] = mapped_column(String(20))
     model: Mapped[str | None] = mapped_column(String(50))
     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)
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
     created_at: Mapped[datetime] = mapped_column(
     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):
 class PrinterResponse(PrinterBase):
     id: int
     id: int
     is_active: bool
     is_active: bool
+    nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
 
 

+ 224 - 0
backend/app/services/bambu_mqtt.py

@@ -31,6 +31,22 @@ class HMSError:
     message: str = ""
     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
 @dataclass
 class PrinterState:
 class PrinterState:
     connected: bool = False
     connected: bool = False
@@ -46,6 +62,7 @@ class PrinterState:
     gcode_file: str | None = None
     gcode_file: str | None = None
     subtask_id: str | None = None
     subtask_id: str | None = None
     hms_errors: list = field(default_factory=list)  # List of HMSError
     hms_errors: list = field(default_factory=list)  # List of HMSError
+    kprofiles: list = field(default_factory=list)  # List of KProfile
 
 
 
 
 class BambuMQTTClient:
 class BambuMQTTClient:
@@ -80,6 +97,11 @@ class BambuMQTTClient:
         self._logging_enabled: bool = False
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
         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
     @property
     def topic_subscribe(self) -> str:
     def topic_subscribe(self) -> str:
         return f"device/{self.serial_number}/report"
         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"[{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')}"
                     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)
             self._update_state(print_data)
 
 
     def _update_state(self, data: dict):
     def _update_state(self, data: dict):
@@ -437,3 +464,200 @@ class BambuMQTTClient:
     def logging_enabled(self) -> bool:
     def logging_enabled(self) -> bool:
         """Check if logging is enabled."""
         """Check if logging is enabled."""
         return self._logging_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 self._clients[printer_id].state.connected
         return False
         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):
     def mark_printer_offline(self, printer_id: int):
         """Mark a printer as offline and trigger status callback.
         """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 { QueuePage } from './pages/QueuePage';
 import { StatsPage } from './pages/StatsPage';
 import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { SettingsPage } from './pages/SettingsPage';
-import { CloudProfilesPage } from './pages/CloudProfilesPage';
+import { ProfilesPage } from './pages/ProfilesPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -38,7 +38,7 @@ function App() {
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="queue" element={<QueuePage />} />
                   <Route path="queue" element={<QueuePage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="stats" element={<StatsPage />} />
-                  <Route path="cloud" element={<CloudProfilesPage />} />
+                  <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                 </Route>
                 </Route>
               </Routes>
               </Routes>

+ 57 - 0
frontend/src/api/client.ts

@@ -28,6 +28,7 @@ export interface Printer {
   ip_address: string;
   ip_address: string;
   access_code: string;
   access_code: string;
   model: string | null;
   model: string | null;
+  nozzle_count: number;  // 1 or 2, auto-detected from MQTT
   is_active: boolean;
   is_active: boolean;
   auto_archive: boolean;
   auto_archive: boolean;
   created_at: string;
   created_at: string;
@@ -311,6 +312,48 @@ export interface MQTTLogsResponse {
   logs: MQTTLogEntry[];
   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
 // API functions
 export const api = {
 export const api = {
   // Printers
   // Printers
@@ -642,4 +685,18 @@ export const api = {
     request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
     request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
   stopQueueItem: (id: number) =>
   stopQueueItem: (id: number) =>
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
     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: 'archives', to: '/archives', icon: Archive, label: 'Archives' },
   { id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
   { id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
   { id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
   { 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' },
   { 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,
   X,
   Key,
   Key,
   RefreshCw,
   RefreshCw,
+  Gauge,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SlicerSetting, SlicerSettingsResponse } from '../api/client';
 import type { SlicerSetting, SlicerSettingsResponse } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { KProfilesView } from '../components/KProfilesView';
+
+type ProfileTab = 'cloud' | 'kprofiles';
 
 
 type LoginStep = 'email' | 'code' | 'token';
 type LoginStep = 'email' | 'code' | 'token';
 
 
@@ -398,9 +402,10 @@ function ProfilesView({ settings }: { settings: SlicerSettingsResponse }) {
   );
   );
 }
 }
 
 
-export function CloudProfilesPage() {
+export function ProfilesPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const [activeTab, setActiveTab] = useState<ProfileTab>('kprofiles');
 
 
   const { data: status, isLoading: statusLoading } = useQuery({
   const { data: status, isLoading: statusLoading } = useQuery({
     queryKey: ['cloudStatus'],
     queryKey: ['cloudStatus'],
@@ -438,58 +443,95 @@ export function CloudProfilesPage() {
 
 
   return (
   return (
     <div className="p-8">
     <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>
       </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>
     </div>
   );
   );
 }
 }

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-B9G6rzsh.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BzGdgzuX.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.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" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio