From e47ef36630961de83bb5abfe3b798959e5cf13f2 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 8 Dec 2025 09:56:21 +0000 Subject: [PATCH] sync --- backend/app/api/routes/cloud.py | 104 + backend/app/api/routes/kprofiles.py | 177 + backend/app/api/routes/print_queue.py | 1 + backend/app/core/database.py | 2 +- backend/app/models/__init__.py | 2 + backend/app/models/kprofile_note.py | 36 + backend/app/models/printer.py | 4 + backend/app/schemas/cloud.py | 38 + backend/app/schemas/kprofile.py | 13 + backend/app/schemas/print_queue.py | 1 + backend/app/services/archive.py | 4 +- backend/app/services/bambu_cloud.py | 163 + backend/app/services/bambu_mqtt.py | 297 +- docs/bambu_lab_preset_sync_api.md | 322 + frontend/src/api/client.ts | 77 +- frontend/src/components/BatchTagModal.tsx | 23 +- .../src/components/EditQueueItemModal.tsx | 243 + frontend/src/components/KProfilesView.tsx | 742 +- .../src/components/KeyboardShortcutsModal.tsx | 5 + frontend/src/pages/ArchivesPage.tsx | 9 +- frontend/src/pages/ProfilesPage.tsx | 1131 ++- frontend/src/pages/QueuePage.tsx | 748 +- static/assets/index-BPFlIKJb.css | 1 + static/assets/index-CUSzovgk.js | 8594 ---------------- static/assets/index-CZD79C6y.js | 8601 +++++++++++++++++ static/assets/index-DL3S7zom.css | 1 - static/index.html | 4 +- 27 files changed, 12163 insertions(+), 9180 deletions(-) create mode 100644 backend/app/models/kprofile_note.py create mode 100644 docs/bambu_lab_preset_sync_api.md create mode 100644 frontend/src/components/EditQueueItemModal.tsx create mode 100644 static/assets/index-BPFlIKJb.css delete mode 100644 static/assets/index-CUSzovgk.js create mode 100644 static/assets/index-CZD79C6y.js delete mode 100644 static/assets/index-DL3S7zom.css diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index 76c39deb5..4364eff68 100644 --- a/backend/app/api/routes/cloud.py +++ b/backend/app/api/routes/cloud.py @@ -24,6 +24,9 @@ from backend.app.schemas.cloud import ( SlicerSettingsResponse, SlicerSetting, CloudDevice, + SlicerSettingCreate, + SlicerSettingUpdate, + SlicerSettingDeleteResponse, ) router = APIRouter(prefix="/cloud", tags=["cloud"]) @@ -287,3 +290,104 @@ async def get_devices(db: AsyncSession = Depends(get_db)): raise HTTPException(status_code=401, detail="Authentication expired") except BambuCloudError as e: raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/settings") +async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)): + """ + Create a new slicer preset/setting. + + Creates a new preset on Bambu Cloud. The preset inherits from a base preset + and only stores the delta (modified values). + + Type should be: 'filament', 'print', or 'printer' + """ + token, _ = await get_stored_token(db) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + cloud = get_cloud_service() + cloud.set_token(token) + + if not cloud.is_authenticated: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + data = await cloud.create_setting( + preset_type=request.type, + name=request.name, + base_id=request.base_id, + setting=request.setting, + version=request.version, + ) + return data + except BambuCloudAuthError: + await clear_token(db) + raise HTTPException(status_code=401, detail="Authentication expired") + except BambuCloudError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/settings/{setting_id}") +async def update_setting( + setting_id: str, + request: SlicerSettingUpdate, + db: AsyncSession = Depends(get_db), +): + """ + Update an existing slicer preset/setting. + + Updates the preset's name and/or settings on Bambu Cloud. + """ + token, _ = await get_stored_token(db) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + cloud = get_cloud_service() + cloud.set_token(token) + + if not cloud.is_authenticated: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + data = await cloud.update_setting( + setting_id=setting_id, + name=request.name, + setting=request.setting, + ) + return data + except BambuCloudAuthError: + await clear_token(db) + raise HTTPException(status_code=401, detail="Authentication expired") + except BambuCloudError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse) +async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)): + """ + Delete a slicer preset/setting. + + Removes the preset from Bambu Cloud. This cannot be undone. + """ + token, _ = await get_stored_token(db) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + cloud = get_cloud_service() + cloud.set_token(token) + + if not cloud.is_authenticated: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + result = await cloud.delete_setting(setting_id) + return SlicerSettingDeleteResponse( + success=result.get("success", True), + message=result.get("message", "Setting deleted"), + ) + except BambuCloudAuthError: + await clear_token(db) + raise HTTPException(status_code=401, detail="Authentication expired") + except BambuCloudError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/routes/kprofiles.py b/backend/app/api/routes/kprofiles.py index e535ef311..d1e2836ba 100644 --- a/backend/app/api/routes/kprofiles.py +++ b/backend/app/api/routes/kprofiles.py @@ -9,11 +9,14 @@ from sqlalchemy import select from backend.app.core.database import get_db from backend.app.models.printer import Printer +from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel from backend.app.schemas.kprofile import ( KProfile, KProfileCreate, KProfileDelete, KProfilesResponse, + KProfileNote, + KProfileNoteResponse, ) from backend.app.services.printer_manager import printer_manager @@ -170,6 +173,64 @@ async def set_kprofile( return {"success": True, "message": message} +@router.post("/batch", response_model=dict) +async def set_kprofiles_batch( + printer_id: int, + profiles: list[KProfileCreate], + db: AsyncSession = Depends(get_db), +): + """Create multiple K-profiles in a single command (for dual-nozzle). + + This sends all profiles in one MQTT command, which is more reliable + for dual-nozzle printers that may not handle sequential commands well. + + Args: + printer_id: ID of the printer + profiles: List of K-profiles to set + """ + if not profiles: + raise HTTPException(400, "No profiles provided") + + logger.info(f"[API] set_kprofiles_batch: printer={printer_id}, {len(profiles)} profiles") + for p in profiles: + logger.info(f" - extruder_id={p.extruder_id}, name={p.name}, k_value={p.k_value}") + + # 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") + + # Build list of profile dicts for batch command + profile_dicts = [ + { + "filament_id": p.filament_id, + "name": p.name, + "k_value": p.k_value, + "nozzle_id": p.nozzle_id, + "extruder_id": p.extruder_id, + "setting_id": p.setting_id, + "slot_id": p.slot_id, + } + for p in profiles + ] + + # Get nozzle_diameter from first profile (all should have same) + nozzle_diameter = profiles[0].nozzle_diameter + + success = client.set_kprofiles_batch(profile_dicts, nozzle_diameter) + + if not success: + raise HTTPException(500, "Failed to send K-profiles batch command") + + return {"success": True, "message": f"Added {len(profiles)} K-profiles"} + + @router.delete("/", response_model=dict) async def delete_kprofile( printer_id: int, @@ -194,6 +255,10 @@ async def delete_kprofile( raise HTTPException(400, "Printer not connected") # Send the delete command to printer + logger.info( + f"[API] delete_kprofile: printer={printer_id}, slot_id={profile.slot_id}, " + f"setting_id={profile.setting_id}, filament_id={profile.filament_id}" + ) success = client.delete_kprofile( cali_idx=profile.slot_id, filament_id=profile.filament_id, @@ -207,3 +272,115 @@ async def delete_kprofile( raise HTTPException(500, "Failed to send K-profile delete command") return {"success": True, "message": "K-profile deleted successfully"} + + +@router.get("/notes", response_model=KProfileNoteResponse) +async def get_kprofile_notes( + printer_id: int, + db: AsyncSession = Depends(get_db), +): + """Get all K-profile notes for a printer. + + Notes are stored locally since printers don't support notes. + + Args: + printer_id: ID of the printer + """ + # 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 all notes for this printer + result = await db.execute( + select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id) + ) + notes = result.scalars().all() + + # Return as a dictionary mapping setting_id -> note + return KProfileNoteResponse( + notes={note.setting_id: note.note for note in notes} + ) + + +@router.put("/notes", response_model=dict) +async def set_kprofile_note( + printer_id: int, + note_data: KProfileNote, + db: AsyncSession = Depends(get_db), +): + """Set or update a note for a K-profile. + + Args: + printer_id: ID of the printer + note_data: The note data (setting_id and note content) + """ + # 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") + + # Find existing note or create new one + result = await db.execute( + select(KProfileNoteModel).where( + KProfileNoteModel.printer_id == printer_id, + KProfileNoteModel.setting_id == note_data.setting_id, + ) + ) + existing_note = result.scalar_one_or_none() + + if note_data.note.strip(): + # Save or update note + if existing_note: + existing_note.note = note_data.note + else: + new_note = KProfileNoteModel( + printer_id=printer_id, + setting_id=note_data.setting_id, + note=note_data.note, + ) + db.add(new_note) + await db.commit() + return {"success": True, "message": "Note saved"} + else: + # Delete note if empty + if existing_note: + await db.delete(existing_note) + await db.commit() + return {"success": True, "message": "Note deleted"} + + +@router.delete("/notes/{setting_id}", response_model=dict) +async def delete_kprofile_note( + printer_id: int, + setting_id: str, + db: AsyncSession = Depends(get_db), +): + """Delete a note for a K-profile. + + Args: + printer_id: ID of the printer + setting_id: The setting_id of the K-profile + """ + # 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") + + # Find and delete the note + result = await db.execute( + select(KProfileNoteModel).where( + KProfileNoteModel.printer_id == printer_id, + KProfileNoteModel.setting_id == setting_id, + ) + ) + existing_note = result.scalar_one_or_none() + + if existing_note: + await db.delete(existing_note) + await db.commit() + + return {"success": True, "message": "Note deleted"} diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index eb722f50a..55cf8fdcd 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -30,6 +30,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: if item.archive: response.archive_name = item.archive.print_name or item.archive.filename response.archive_thumbnail = item.archive.thumbnail_path + response.print_time_seconds = item.archive.print_time_seconds if item.printer: response.printer_name = item.printer.name return response diff --git a/backend/app/core/database.py b/backend/app/core/database.py index a9ddcc977..c7fb3503a 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -34,7 +34,7 @@ async def get_db() -> AsyncSession: async def init_db(): # Import models to register them with SQLAlchemy - from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance # noqa: F401 + from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2e0545489..6e5b0b572 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from backend.app.models.filament import Filament from backend.app.models.settings import Settings from backend.app.models.smart_plug import SmartPlug from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory +from backend.app.models.kprofile_note import KProfileNote __all__ = [ "Printer", @@ -14,4 +15,5 @@ __all__ = [ "MaintenanceType", "PrinterMaintenance", "MaintenanceHistory", + "KProfileNote", ] diff --git a/backend/app/models/kprofile_note.py b/backend/app/models/kprofile_note.py new file mode 100644 index 000000000..a4c227f56 --- /dev/null +++ b/backend/app/models/kprofile_note.py @@ -0,0 +1,36 @@ +"""Model for K-profile notes stored locally (not on printer).""" + +from datetime import datetime +from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class KProfileNote(Base): + """Notes for K-profiles stored locally since printers don't support notes.""" + + __tablename__ = "kprofile_notes" + + id: Mapped[int] = mapped_column(primary_key=True) + printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE")) + # setting_id is the unique identifier for a K-profile on the printer + setting_id: Mapped[str] = mapped_column(String(100)) + note: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + # Relationship to printer + printer: Mapped["Printer"] = relationship(back_populates="kprofile_notes") + + # Composite index for efficient lookups + __table_args__ = ( + Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True), + ) + + +from backend.app.models.printer import Printer # noqa: E402 diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index ab30794db..411ebc7a0 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -39,9 +39,13 @@ class Printer(Base): maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship( back_populates="printer", cascade="all, delete-orphan" ) + kprofile_notes: Mapped[list["KProfileNote"]] = relationship( + back_populates="printer", cascade="all, delete-orphan" + ) from backend.app.models.archive import PrintArchive # noqa: E402 +from backend.app.models.kprofile_note import KProfileNote # noqa: E402 from backend.app.models.smart_plug import SmartPlug # noqa: E402 from backend.app.models.notification import NotificationProvider # noqa: E402 from backend.app.models.maintenance import PrinterMaintenance # noqa: E402 diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py index ac1868558..451eea846 100644 --- a/backend/app/schemas/cloud.py +++ b/backend/app/schemas/cloud.py @@ -57,3 +57,41 @@ class CloudDevice(BaseModel): dev_model_name: Optional[str] = None dev_product_name: Optional[str] = None online: bool = False + + +class SlicerSettingCreate(BaseModel): + """Request to create a new slicer preset.""" + type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'") + name: str = Field(..., description="Display name for the preset") + base_id: str = Field(..., description="Base preset ID to inherit from") + version: str = Field(default="2.0.0.0", description="Version string for the preset") + setting: dict = Field(default_factory=dict, description="Setting key-value pairs (delta from base)") + + +class SlicerSettingUpdate(BaseModel): + """Request to update an existing slicer preset.""" + name: Optional[str] = Field(None, description="New display name") + setting: Optional[dict] = Field(None, description="Setting key-value pairs to update") + + +class SlicerSettingDetail(BaseModel): + """Detailed slicer setting/preset response.""" + message: Optional[str] = None + code: Optional[str] = None + error: Optional[str] = None + public: bool = False + version: Optional[str] = None + type: str + name: str + update_time: Optional[str] = None + nickname: Optional[str] = None + base_id: Optional[str] = None + setting: dict = Field(default_factory=dict) + filament_id: Optional[str] = None + setting_id: Optional[str] = None # For response after create + + +class SlicerSettingDeleteResponse(BaseModel): + """Response from deleting a preset.""" + success: bool + message: str diff --git a/backend/app/schemas/kprofile.py b/backend/app/schemas/kprofile.py index 04d95ea06..584acb3ba 100644 --- a/backend/app/schemas/kprofile.py +++ b/backend/app/schemas/kprofile.py @@ -51,3 +51,16 @@ class KProfileDelete(BaseModel): nozzle_diameter: str # e.g., "0.4" filament_id: str # Bambu filament identifier setting_id: str | None = None # Setting ID (for X1C series) + + +class KProfileNote(BaseModel): + """Schema for K-profile notes (stored locally, not on printer).""" + + setting_id: str # Unique identifier for the K-profile + note: str # The note content + + +class KProfileNoteResponse(BaseModel): + """Response containing notes for K-profiles.""" + + notes: dict[str, str] # mapping of setting_id -> note diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index c9e2e024a..225bf1b66 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -48,6 +48,7 @@ class PrintQueueItemResponse(BaseModel): archive_name: str | None = None archive_thumbnail: str | None = None printer_name: str | None = None + print_time_seconds: int | None = None # Estimated print time from archive class Config: from_attributes = True diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 2ce245a8c..a28b6f222 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -573,8 +573,10 @@ class ArchiveService: name_conditions.append(PrintArchive.print_name.ilike(print_name)) if makerworld_model_id: # Match by MakerWorld model ID stored in extra_data + # Use json_extract for SQLite compatibility (astext is PostgreSQL-only) + from sqlalchemy import func, cast, String name_conditions.append( - PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id + func.json_extract(PrintArchive.extra_data, '$.makerworld_model_id') == str(makerworld_model_id) ) if name_conditions: diff --git a/backend/app/services/bambu_cloud.py b/backend/app/services/bambu_cloud.py index 393c11032..7bef50fbc 100644 --- a/backend/app/services/bambu_cloud.py +++ b/backend/app/services/bambu_cloud.py @@ -220,6 +220,169 @@ class BambuCloudService: except httpx.RequestError as e: raise BambuCloudError(f"Request failed: {e}") + async def create_setting(self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0") -> dict: + """ + Create a new slicer preset/setting. + + Args: + preset_type: Type of preset - "filament", "print", or "printer" + name: Display name for the preset + base_id: Base preset ID to inherit from (e.g., "GFSA00") + setting: Dict of setting key-value pairs (only modified values from base) + version: Version string for the preset (default: "2.0.0.0") + + Returns: + Created preset data including the new setting_id + """ + if not self.is_authenticated: + raise BambuCloudAuthError("Not authenticated") + + try: + # Add timestamp if not present + import time + if "updated_time" not in setting: + setting["updated_time"] = str(int(time.time())) + + payload = { + "type": preset_type, + "name": name, + "version": version, + "base_id": base_id, + "setting": setting, + } + + response = await self._client.post( + f"{self.base_url}/v1/iot-service/api/slicer/setting", + headers=self._get_headers(), + json=payload + ) + + data = response.json() + + if response.status_code in (200, 201): + return data + + error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}" + raise BambuCloudError(f"Failed to create setting: {error_msg}") + + except httpx.RequestError as e: + raise BambuCloudError(f"Request failed: {e}") + + async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict: + """ + Update an existing slicer preset/setting. + + Note: Bambu Cloud API doesn't support true updates. Instead, we: + 1. Fetch the current setting metadata (type, base_id, version) + 2. Use the provided settings as the new complete settings (NOT merged) + 3. Delete the old setting first (to avoid name conflicts) + 4. Create a new setting via POST + + Args: + setting_id: ID of the preset to update + name: New display name (optional) + setting: Dict of setting key-value pairs - this REPLACES the old settings entirely + + Returns: + Updated preset data with new setting_id + """ + if not self.is_authenticated: + raise BambuCloudAuthError("Not authenticated") + + try: + # Fetch current setting to get metadata (type, base_id, version) + current = await self.get_setting_detail(setting_id) + preset_type = current.get("type", "filament") + + # Use provided settings directly (complete replacement, not merge) + # This allows the frontend to edit the full settings JSON + if setting is not None: + updated_setting = setting.copy() + else: + updated_setting = current.get("setting", {}).copy() + + # Extract name from settings_id field in the JSON, or use provided name, or fall back to current + # The settings_id field contains the name in quotes, e.g., '"My Preset Name"' + settings_id_key = { + "filament": "filament_settings_id", + "print": "print_settings_id", + "printer": "printer_settings_id", + }.get(preset_type, "filament_settings_id") + + settings_id_value = updated_setting.get(settings_id_key, "") + if settings_id_value: + # Remove surrounding quotes if present (e.g., '"foo"' -> 'foo') + updated_name = settings_id_value.strip('"') + elif name is not None: + updated_name = name + else: + updated_name = current.get("name", "Untitled") + + # Update the timestamp + import time + updated_setting["updated_time"] = str(int(time.time())) + + # Ensure settings_id field matches the name + updated_setting[settings_id_key] = f'"{updated_name}"' + + # Delete the old setting FIRST to avoid name conflicts + await self.delete_setting(setting_id) + + # Create new setting via POST + payload = { + "type": preset_type, + "name": updated_name, + "version": current.get("version", "2.0.0.0"), + "base_id": current.get("base_id", ""), + "setting": updated_setting, + } + + response = await self._client.post( + f"{self.base_url}/v1/iot-service/api/slicer/setting", + headers=self._get_headers(), + json=payload + ) + + data = response.json() + + if response.status_code == 200: + return data + + error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}" + raise BambuCloudError(f"Failed to update setting: {error_msg}") + + except httpx.RequestError as e: + raise BambuCloudError(f"Request failed: {e}") + + async def delete_setting(self, setting_id: str) -> dict: + """ + Delete a slicer preset/setting. + + Args: + setting_id: ID of the preset to delete + + Returns: + Deletion confirmation + """ + if not self.is_authenticated: + raise BambuCloudAuthError("Not authenticated") + + try: + response = await self._client.delete( + f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", + headers=self._get_headers() + ) + + if response.status_code in (200, 204): + return {"success": True, "message": "Setting deleted"} + + data = response.json() if response.content else {} + error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}" + raise BambuCloudError(f"Failed to delete setting: {error_msg}") + + except httpx.RequestError as e: + raise BambuCloudError(f"Request failed: {e}") + async def get_devices(self) -> dict: """Get list of bound devices.""" if not self.is_authenticated: diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 767dd898e..bab0636ae 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -1,3 +1,12 @@ +"""Bambu Lab MQTT communication service. + +IMPORTANT: Always use qos=1 for all MQTT publish calls! +The printer ignores qos=0 messages when busy broadcasting status updates. +Using qos=1 ensures the printer acknowledges and processes our commands immediately. +This was discovered when K-profile requests with qos=0 took 20-30 seconds, +but with qos=1 they respond instantly. +""" + import json import ssl import asyncio @@ -1411,7 +1420,7 @@ class BambuMQTTClient: """Request full status update from printer.""" if self._client: message = {"pushing": {"command": "pushall"}} - self._client.publish(self.topic_publish, json.dumps(message)) + self._client.publish(self.topic_publish, json.dumps(message), qos=1) def request_status_update(self) -> bool: """Request a full status update from the printer (public API). @@ -1441,7 +1450,7 @@ class BambuMQTTClient: } } logger.debug(f"[{self.serial_number}] Requesting accessories info") - self._client.publish(self.topic_publish, json.dumps(message)) + self._client.publish(self.topic_publish, json.dumps(message), qos=1) def _prime_kprofile_request(self): """Send a priming K-profile request on connect. @@ -1460,7 +1469,7 @@ class BambuMQTTClient: } } logger.debug(f"[{self.serial_number}] Sending K-profile priming request") - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) def connect(self, loop: asyncio.AbstractEventLoop | None = None): """Connect to the printer MQTT broker. @@ -1516,7 +1525,7 @@ class BambuMQTTClient: } } logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}") - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) return True return False @@ -1529,7 +1538,7 @@ class BambuMQTTClient: "sequence_id": "0" } } - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) logger.info(f"[{self.serial_number}] Sent stop print command") return True return False @@ -1763,7 +1772,7 @@ class BambuMQTTClient: direction="out", payload=command, )) - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) def enable_logging(self, enabled: bool = True): """Enable or disable MQTT message logging.""" @@ -1785,13 +1794,50 @@ class BambuMQTTClient: def _handle_kprofile_response(self, data: dict): """Handle K-profile response from printer.""" + response_nozzle = data.get("nozzle_diameter") + response_seq_id = data.get("sequence_id", "?") filaments = data.get("filaments", []) - profiles = [] + expected_nozzle = getattr(self, '_expected_kprofile_nozzle', None) + has_pending_request = self._pending_kprofile_response is not None - # 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]}") + # Log all incoming responses when we have a pending request (for debugging) + if has_pending_request: + logger.info(f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, {len(filaments)} profiles, expected={expected_nozzle}") + + # If we have a pending request, only accept responses with matching nozzle_diameter + # The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response + if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle: + # Ignore this broadcast, keep waiting for matching response + logger.debug(f"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}") + return + + # If no pending request, this is just a broadcast - update state silently and return early + if not has_pending_request: + # Still parse profiles to keep state updated, but don't log + profiles = [] + for f in filaments: + if isinstance(f, dict): + try: + cali_idx = f.get("cali_idx", 0) + 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): + pass + self.state.kprofiles = profiles + return + + profiles = [] for i, f in enumerate(filaments): if isinstance(f, dict): @@ -1817,17 +1863,16 @@ class BambuMQTTClient: self.state.kprofiles = profiles self._kprofile_response_data = profiles - # Signal that we received the response + # Signal that we received the response (only if we were waiting for one) # Use thread-safe method since MQTT callbacks run in a different thread if self._pending_kprofile_response: + logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={response_nozzle}") if self._loop and self._loop.is_running(): self._loop.call_soon_threadsafe(self._pending_kprofile_response.set) else: # Fallback for when loop is not available 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, max_retries: int = 3) -> list[KProfile]: """Request K-profiles from the printer with retry logic. @@ -1858,8 +1903,9 @@ class BambuMQTTClient: self._sequence_id += 1 self._pending_kprofile_response = asyncio.Event() self._kprofile_response_data = None + self._expected_kprofile_nozzle = nozzle_diameter # Track which nozzle response we expect - # Send the command + # Send the command with nozzle_diameter filter command = { "print": { "command": "extrusion_cali_get", @@ -1869,14 +1915,15 @@ class BambuMQTTClient: } } - logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter} (attempt {attempt + 1}/{max_retries})") - self._client.publish(self.topic_publish, json.dumps(command)) + logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})") + logger.debug(f"[{self.serial_number}] K-profile request JSON: {json.dumps(command)}") + self._client.publish(self.topic_publish, json.dumps(command), qos=1) - # Wait for response + # Wait for response (response handler already filters by nozzle_diameter) try: await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout) profiles = self._kprofile_response_data or [] - logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles on attempt {attempt + 1}") + logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}") return profiles except asyncio.TimeoutError: logger.warning(f"[{self.serial_number}] Timeout on K-profiles request attempt {attempt + 1}/{max_retries}") @@ -1885,6 +1932,7 @@ class BambuMQTTClient: await asyncio.sleep(0.5) finally: self._pending_kprofile_response = None + self._expected_kprofile_nozzle = None logger.error(f"[{self.serial_number}] Failed to get K-profiles after {max_retries} attempts") return [] @@ -1912,7 +1960,7 @@ class BambuMQTTClient: 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 - cali_idx: For H2D edits, the existing slot being edited (enables in-place edit) + cali_idx: For edits, the existing slot being edited (enables in-place edit) Returns: True if command was sent, False otherwise @@ -1923,95 +1971,111 @@ class BambuMQTTClient: self._sequence_id += 1 - # Detect printer type by serial number prefix - # X1C/P1/A1 series (single nozzle): serial starts with "00M", "00W", "01P", "01S", "03W", etc. - # H2D series (dual nozzle): serial starts with "094" - is_dual_nozzle = self.serial_number.startswith("094") - - # For H2D edits, use empty setting_id per OrcaSlicer sniff - # For new profiles, generate a setting_id - import secrets + # 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 + # For edits, use the provided cali_idx or slot_id if cali_idx is not None: - # Edit mode - use empty setting_id per OrcaSlicer sniff - setting_id = "" - elif not setting_id and slot_id == 0: - # New profile - generate setting_id - setting_id = f"PFUS{secrets.token_hex(7)}" # 7 bytes = 14 hex chars - - if is_dual_nozzle: - # H2D format - exact OrcaSlicer format (captured via MQTT sniffing) - # For edits: include cali_idx (existing slot), slot_id=0, setting_id="" - # For new profiles: no cali_idx, slot_id=0, setting_id=generated - filament_entry = { - "ams_id": 0, - "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 if setting_id else "", - "slot_id": slot_id, - "tray_id": -1, - } - # For edits, add cali_idx field (position matters - alphabetical order) - if cali_idx is not None: - # Insert cali_idx in alphabetical position (after ams_id, before extruder_id) - # n_coef must be "0.000000" for H2D edits (matches OrcaSlicer sniff) - 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": "", - "slot_id": 0, - "tray_id": -1, - } - command = { - "print": { - "command": "extrusion_cali_set", - "filaments": [filament_entry], - "nozzle_diameter": nozzle_diameter, - "sequence_id": str(self._sequence_id), - } - } + effective_cali_idx = cali_idx else: - # X1C/P1/A1 format - based on actual X1C profile data: - # - n_coef: "1.000000" (NOT 0.000000 like H2D) - # - nozzle_id: "" (empty string, NOT the nozzle type) - # - tray_id: -1 (NOT 0) - filament_entry = { - "ams_id": 0, - "extruder_id": 0, # X1C is single nozzle - "filament_id": filament_id, - "k_value": k_value, - "n_coef": "1.000000", # X1C uses 1.0, not 0.0 - "name": name, + effective_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": effective_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 if setting_id else "", + "tray_id": -1, + } + + command = { + "print": { + "command": "extrusion_cali_set", + "filaments": [filament_entry], "nozzle_diameter": nozzle_diameter, - "nozzle_id": "", # X1C uses empty string - "setting_id": setting_id, - "slot_id": slot_id, - "tray_id": -1, # X1C uses -1 - } - command = { - "print": { - "command": "extrusion_cali_set", - "filaments": [filament_entry], - "nozzle_diameter": nozzle_diameter, - "sequence_id": str(self._sequence_id), - } + "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}, dual={is_dual_nozzle})") + logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id==0})") logger.info(f"[{self.serial_number}] K-profile SET command: {command_json}") - # Use QoS 1 for reliable delivery (at least once) + self._client.publish(self.topic_publish, command_json, qos=1) + return True + + def set_kprofiles_batch( + self, + profiles: list[dict], + nozzle_diameter: str = "0.4", + ) -> bool: + """Set multiple K-profiles in a single command (for dual-nozzle). + + Args: + profiles: List of profile dicts, each with: + - filament_id, name, k_value, nozzle_id, extruder_id, setting_id (optional), slot_id + nozzle_diameter: Common nozzle diameter for all profiles + + 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-profiles batch: not connected") + return False + + import random + self._sequence_id += 1 + + filament_entries = [] + for p in profiles: + slot_id = p.get("slot_id", 0) + cali_idx = p.get("cali_idx") + + if cali_idx is not None: + effective_cali_idx = cali_idx + else: + effective_cali_idx = -1 if slot_id == 0 else slot_id + + setting_id = p.get("setting_id") + if not setting_id and slot_id == 0: + setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}" + + filament_entries.append({ + "ams_id": 0, + "cali_idx": effective_cali_idx, + "extruder_id": p.get("extruder_id", 0), + "filament_id": p.get("filament_id", ""), + "k_value": p.get("k_value", "0.020000"), + "n_coef": "0.000000", + "name": p.get("name", ""), + "nozzle_diameter": nozzle_diameter, + "nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"), + "setting_id": setting_id if setting_id else "", + "tray_id": -1, + }) + + command = { + "print": { + "command": "extrusion_cali_set", + "filaments": filament_entries, + "nozzle_diameter": nozzle_diameter, + "sequence_id": str(self._sequence_id), + } + } + + command_json = json.dumps(command) + logger.info(f"[{self.serial_number}] Setting {len(filament_entries)} K-profiles in batch") + logger.info(f"[{self.serial_number}] K-profile SET batch command: {command_json}") self._client.publish(self.topic_publish, command_json, qos=1) return True @@ -2061,20 +2125,23 @@ class BambuMQTTClient: } } else: - # X1C/P1/A1 format: uses setting_id, nozzle_diameter, no extruder/nozzle_id fields + # X1C/P1/A1 format: include all fields like the set command + # The delete command structure should match what set uses command = { "print": { "command": "extrusion_cali_del", "sequence_id": str(self._sequence_id), "filament_id": filament_id, "cali_idx": cali_idx, - "setting_id": setting_id, + "setting_id": setting_id if setting_id else "", "nozzle_diameter": nozzle_diameter, + "nozzle_id": nozzle_id, + "extruder_id": extruder_id, } } command_json = json.dumps(command) - logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, dual={is_dual_nozzle}") + logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}") logger.info(f"[{self.serial_number}] K-profile DELETE command: {command_json}") # Use QoS 1 for reliable delivery (at least once) self._client.publish(self.topic_publish, command_json, qos=1) @@ -2221,7 +2288,7 @@ class BambuMQTTClient: "sequence_id": "0" } } - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) logger.info(f"[{self.serial_number}] Set print speed mode to {mode}") return True @@ -2443,7 +2510,7 @@ class BambuMQTTClient: command_json = json.dumps(command) logger.info(f"[{self.serial_number}] Publishing ams_change_filament command: {command_json}") - self._client.publish(self.topic_publish, command_json) + self._client.publish(self.topic_publish, command_json, qos=1) logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id} (AMS {ams_id} slot {slot_id})") # Track this load request for H2D dual-nozzle disambiguation @@ -2498,7 +2565,7 @@ class BambuMQTTClient: command_json = json.dumps(command) logger.info(f"[{self.serial_number}] Publishing ams_change_filament (unload) command: {command_json}") - self._client.publish(self.topic_publish, command_json) + self._client.publish(self.topic_publish, command_json, qos=1) logger.info(f"[{self.serial_number}] Unloading filament (tray_now was {tray_now})") # Clear tracked load request since we're unloading @@ -2532,7 +2599,7 @@ class BambuMQTTClient: "sequence_id": "0" } } - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) logger.info(f"[{self.serial_number}] AMS control: {action}") return True @@ -2576,7 +2643,7 @@ class BambuMQTTClient: "sequence_id": "0" } } - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}") return True, f"Refreshing AMS {ams_id} tray {tray_id}" @@ -2631,7 +2698,7 @@ class BambuMQTTClient: command_json = json.dumps(command) logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}") logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}") - self._client.publish(self.topic_publish, command_json) + self._client.publish(self.topic_publish, command_json, qos=1) return True def set_timelapse(self, enable: bool) -> bool: @@ -2661,9 +2728,9 @@ class BambuMQTTClient: "sequence_id": "0" } } - self._client.publish(self.topic_publish, json.dumps(timelapse_cmd)) + self._client.publish(self.topic_publish, json.dumps(timelapse_cmd), qos=1) # Request status update - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) logger.info(f"[{self.serial_number}] Set timelapse {'enabled' if enable else 'disabled'}") return True @@ -2687,7 +2754,7 @@ class BambuMQTTClient: "sequence_id": "0" } } - self._client.publish(self.topic_publish, json.dumps(command)) + self._client.publish(self.topic_publish, json.dumps(command), qos=1) # Request status update pushall = { "pushing": { @@ -2695,6 +2762,6 @@ class BambuMQTTClient: "sequence_id": "0" } } - self._client.publish(self.topic_publish, json.dumps(pushall)) + self._client.publish(self.topic_publish, json.dumps(pushall), qos=1) logger.info(f"[{self.serial_number}] Set liveview {'enabled' if enable else 'disabled'}") return True diff --git a/docs/bambu_lab_preset_sync_api.md b/docs/bambu_lab_preset_sync_api.md new file mode 100644 index 000000000..cac7e3689 --- /dev/null +++ b/docs/bambu_lab_preset_sync_api.md @@ -0,0 +1,322 @@ +# Bambu Lab Preset Sync API Documentation + +This document describes the Bambu Lab cloud API endpoints for syncing slicer presets (filament, print process, and machine profiles) between Bambu Studio and the cloud. + +**Captured from:** Bambu Studio v2.4.0.70 with bambu_network_agent v02.04.00.58 +**Date:** 2025-12-08 + +--- + +## Authentication + +All API requests require authentication via Bearer token. + +### Required Headers + +```http +Host: api.bambulab.com +Authorization: Bearer +User-Agent: bambu_network_agent/02.04.00.58 +X-BBL-Client-Name: BambuStudio +X-BBL-Client-Type: slicer +X-BBL-Client-Version: 02.04.00.70 +X-BBL-Device-ID: +X-BBL-Language: en-US +X-BBL-OS-Type: macos|windows|linux +X-BBL-OS-Version: +X-BBL-Agent-Version: 02.04.00.58 +accept: application/json +``` + +--- + +## Endpoints + +### 1. Get User Profile + +```http +GET /v1/user-service/my/profile +``` + +Returns user account information including UID. + +### 2. List All User Presets + +```http +GET /v1/iot-service/api/slicer/setting?version={slicer_version}&public=false +``` + +**Parameters:** +- `version`: Slicer version (e.g., `2.4.0.5`) +- `public`: Set to `false` for user presets only + +**Response:** Returns a list of preset IDs that the user has synced to cloud. + +### 3. Get Individual Preset + +```http +GET /v1/iot-service/api/slicer/setting/{preset_id} +``` + +**Response:** +```json +{ + "message": "success", + "code": null, + "error": null, + "public": false, + "version": "1.5.0.20", + "type": "filament", + "name": "Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle", + "update_time": "2025-12-08 01:06:27", + "nickname": null, + "base_id": "GFSA00", + "setting": { + "inherits": "Bambu PLA Basic @BBL X1C", + "filament_vendor": "\"Devil Design\"", + "nozzle_temperature": "225,220", + "pressure_advance": "0.03", + "updated_time": "1765138658" + }, + "filament_id": null +} +``` + +--- + +## Preset ID Naming Convention + +Preset IDs follow a specific prefix pattern indicating the type: + +| Prefix | Type | Description | +|--------|------|-------------| +| `PPUS` | Print Process | Print/quality settings (layer height, speeds, infill, etc.) | +| `PFUS` | Filament | Filament settings (temperatures, flow, pressure advance, etc.) | +| `PMUS` | Printer/Machine | Machine settings (gcode, bed size, kinematics, etc.) | + +The suffix after the prefix is a unique hash identifier. + +**Examples:** +- `PPUS1b03400426f57d` - Print process preset +- `PFUS169056f3003bb4` - Filament preset +- `PMUSbc396893c54df0` - Machine/printer preset + +--- + +## Preset Response Schema + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `message` | string | API response status ("success") | +| `code` | int/null | Error code if any | +| `error` | string/null | Error message if any | +| `public` | boolean | Whether preset is publicly shared | +| `version` | string | Preset version | +| `type` | string | Preset type: "filament", "print", or "printer" | +| `name` | string | Display name of the preset | +| `update_time` | string | Last update timestamp (ISO format) | +| `nickname` | string/null | Optional user-defined nickname | +| `base_id` | string | Reference ID of the parent/base preset | +| `setting` | object | Key-value pairs of customized settings | +| `filament_id` | string/null | Bambu filament ID if applicable | + +### Setting Object + +The `setting` object contains **only the delta/modified values** from the parent preset. Key fields include: + +- `inherits`: Name of the parent preset this inherits from +- `updated_time`: Unix timestamp of last modification +- Other fields depend on preset type (see below) + +--- + +## Preset Types and Common Settings + +### Filament Presets (PFUS) + +```json +{ + "inherits": "Bambu PLA Basic @BBL X1C", + "filament_vendor": "\"Devil Design\"", + "filament_cost": "20", + "filament_settings_id": "\"Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle\"", + "nozzle_temperature": "225,220", + "nozzle_temperature_initial_layer": "225,220", + "hot_plate_temp": "60", + "cool_plate_temp": "60", + "textured_plate_temp": "60", + "pressure_advance": "0.03", + "enable_pressure_advance": "1", + "filament_max_volumetric_speed": "30,29", + "activate_air_filtration": "1", + "during_print_exhaust_fan_speed": "50", + "complete_print_exhaust_fan_speed": "50", + "close_fan_the_first_x_layers": "2", + "overhang_fan_threshold": "10%", + "slow_down_layer_time": "5", + "temperature_vitrification": "65", + "filament_start_gcode": "...", + "filament_end_gcode": "..." +} +``` + +### Print Process Presets (PPUS) + +```json +{ + "inherits": "0.08mm Extra Fine @BBL H2D", + "print_settings_id": "# 0.08mm Extra Fine @BBL H2D", + "prime_tower_max_speed": "100", + "prime_tower_rib_wall": "0", + "prime_tower_width": "20" +} +``` + +### Machine/Printer Presets (PMUS) + +```json +{ + "inherits": "Bambu Lab H2D 0.4 nozzle", + "printer_settings_id": "# Bambu Lab H2D 0.4 nozzle", + "bed_custom_model": "/path/to/model.stl", + "machine_start_gcode": "...", + "machine_end_gcode": "...", + "change_filament_gcode": "...", + "printer_notes": "...", + "support_air_filtration": "1" +} +``` + +--- + +## Base ID Reference + +The `base_id` field references Bambu's internal preset database: + +| Prefix | Type | +|--------|------| +| `GF` | Generic Filament | +| `GP` | Generic Print Process | +| `GM` | Generic Machine | + +Examples: +- `GFSA00` - Generic filament base +- `GP136` - Generic print process base +- `GM033` - Generic machine base (H2D) + +--- + +## API Operations (Verified) + +### Create Preset + +```http +POST /v1/iot-service/api/slicer/setting +Content-Type: application/json + +{ + "type": "filament", + "name": "My Custom PLA", + "version": "2.0.0.0", + "base_id": "GFSA00", + "setting": { + "inherits": "Bambu PLA Basic @BBL X1C", + "nozzle_temperature": "210,205", + "updated_time": "1733665800" + } +} +``` + +**Required fields:** +- `type`: "filament", "print", or "printer" +- `name`: Display name +- `version`: Version string (e.g., "2.0.0.0", "2.3.0.2") +- `base_id`: Parent preset ID +- `setting`: Object with modified values including `updated_time` (Unix timestamp) + +**Response:** +```json +{ + "message": "success", + "code": null, + "error": null, + "setting_id": "PFUSe99f2ff04974b4", + "update_time": "2025-12-08 16:31:48" +} +``` + +### Update Preset + +**Important:** The Bambu Cloud API does NOT support true updates via PUT/PATCH. + +- `PUT /v1/iot-service/api/slicer/setting/{preset_id}` returns **405 Method Not Allowed** +- `PATCH /v1/iot-service/api/slicer/setting/{preset_id}` returns **500 Cloud database failed** + +**Workaround:** To "update" a preset: +1. GET the existing preset details +2. Merge your changes +3. POST to create a new preset (returns new `setting_id`) +4. DELETE the old preset + +This mimics how Bambu Studio handles preset updates. + +### Delete Preset + +```http +DELETE /v1/iot-service/api/slicer/setting/{preset_id} +``` + +**Response:** +```json +{ + "message": "success", + "code": null, + "error": null +} +``` + +--- + +## Related Endpoints + +### Slicer Resources + +```http +GET /v1/iot-service/api/slicer/resource?slicer/plugins/cloud={version} +GET /v1/iot-service/api/slicer/resource?slicer/printer/bbl={version} +GET /v1/iot-service/api/slicer/resource?policy/privacy={version} +``` + +### User Print Status + +```http +GET /v1/iot-service/api/user/print?force=true +``` + +### User Tasks + +```http +GET /v1/user-service/my/tasks?limit=5&offset=0&status=0 +``` + +### MQTT Certificate + +```http +GET /v1/iot-service/api/user/applications/{app_id}/cert?aes256={encrypted_key} +``` + +--- + +## Notes + +1. **Delta Storage**: Presets only store modified values from the parent, using the `inherits` field to reference the base preset. + +2. **Version Tracking**: The `updated_time` field (Unix timestamp) is used for sync conflict resolution. + +3. **Gcode Escaping**: Gcode fields use `\\n` for newlines within JSON strings. + +4. **Multi-value Fields**: Some fields like `nozzle_temperature` contain comma-separated values for different conditions. + +5. **Authentication**: The access token can be obtained via Bambu Lab OAuth flow or the `/v1/user-service/user/ticket/{code}` endpoint. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 059a21aa5..b95b6b102 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -283,6 +283,39 @@ export interface SlicerSettingsResponse { process: SlicerSetting[]; } +export interface SlicerSettingDetail { + message?: string | null; + code?: string | null; + error?: string | null; + public: boolean; + version?: string | null; + type: string; + name: string; + update_time?: string | null; + nickname?: string | null; + base_id?: string | null; + setting: Record; + filament_id?: string | null; + setting_id?: string | null; +} + +export interface SlicerSettingCreate { + type: string; // 'filament', 'print', or 'printer' + name: string; + base_id: string; + setting: Record; +} + +export interface SlicerSettingUpdate { + name?: string; + setting?: Record; +} + +export interface SlicerSettingDeleteResponse { + success: boolean; + message: string; +} + export interface CloudDevice { dev_id: string; name: string; @@ -382,6 +415,7 @@ export interface PrintQueueItem { archive_name?: string | null; archive_thumbnail?: string | null; printer_name?: string | null; + print_time_seconds?: number | null; // Estimated print time from archive } export interface PrintQueueItemCreate { @@ -456,6 +490,15 @@ export interface KProfilesResponse { nozzle_diameter: string; } +export interface KProfileNote { + setting_id: string; + note: string; +} + +export interface KProfileNotesResponse { + notes: Record; // setting_id -> note +} + // Slot Preset Mapping export interface SlotPresetMapping { ams_id: number; @@ -1004,7 +1047,21 @@ export const api = { getCloudSettings: (version = '01.09.00.00') => request(`/cloud/settings?version=${version}`), getCloudSettingDetail: (settingId: string) => - request>(`/cloud/settings/${settingId}`), + request(`/cloud/settings/${settingId}`), + createCloudSetting: (data: SlicerSettingCreate) => + request('/cloud/settings', { + method: 'POST', + body: JSON.stringify(data), + }), + updateCloudSetting: (settingId: string, data: SlicerSettingUpdate) => + request(`/cloud/settings/${settingId}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + deleteCloudSetting: (settingId: string) => + request(`/cloud/settings/${settingId}`, { + method: 'DELETE', + }), getCloudDevices: () => request('/cloud/devices'), // Smart Plugs @@ -1079,6 +1136,24 @@ export const api = { method: 'DELETE', body: JSON.stringify(profile), }), + setKProfilesBatch: (printerId: number, profiles: KProfileCreate[]) => + request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/batch`, { + method: 'POST', + body: JSON.stringify(profiles), + }), + + // K-Profile Notes (stored locally, not on printer) + getKProfileNotes: (printerId: number) => + request(`/printers/${printerId}/kprofiles/notes`), + setKProfileNote: (printerId: number, settingId: string, note: string) => + request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes`, { + method: 'PUT', + body: JSON.stringify({ setting_id: settingId, note }), + }), + deleteKProfileNote: (printerId: number, settingId: string) => + request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes/${encodeURIComponent(settingId)}`, { + method: 'DELETE', + }), // Slot Preset Mappings getSlotPresets: (printerId: number) => diff --git a/frontend/src/components/BatchTagModal.tsx b/frontend/src/components/BatchTagModal.tsx index e2f540d6f..b8c60e1bf 100644 --- a/frontend/src/components/BatchTagModal.tsx +++ b/frontend/src/components/BatchTagModal.tsx @@ -31,8 +31,11 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo const batchTagMutation = useMutation({ mutationFn: async () => { const tagsArray = Array.from(selectedTags); - await Promise.all( - selectedIds.map(async (id) => { + let successCount = 0; + + // Process sequentially to avoid SQLite database locks + for (const id of selectedIds) { + try { const archive = await api.getArchive(id); const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : []; @@ -45,18 +48,22 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo newTags = currentTags.filter(t => !selectedTags.has(t)); } - return api.updateArchive(id, { tags: newTags.join(', ') }); - }) - ); - return { count: selectedIds.length, mode, tags: tagsArray }; + await api.updateArchive(id, { tags: newTags.join(', ') }); + successCount++; + } catch (err) { + console.error(`Failed to update archive ${id}:`, err); + throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + return { count: successCount, mode, tags: tagsArray }; }, onSuccess: ({ count, mode, tags }) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`); onClose(); }, - onError: () => { - showToast('Failed to update tags', 'error'); + onError: (error: Error) => { + showToast(error.message || 'Failed to update tags', 'error'); }, }); diff --git a/frontend/src/components/EditQueueItemModal.tsx b/frontend/src/components/EditQueueItemModal.tsx new file mode 100644 index 000000000..50d15b7aa --- /dev/null +++ b/frontend/src/components/EditQueueItemModal.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Calendar, Clock, X, AlertCircle, Power, Pencil } from 'lucide-react'; +import { api } from '../api/client'; +import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client'; +import { Card, CardContent } from './Card'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; + +interface EditQueueItemModalProps { + item: PrintQueueItem; + onClose: () => void; +} + +export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const [printerId, setPrinterId] = useState(item.printer_id); + + // Check if scheduled_time is a "placeholder" far-future date (more than 6 months out) + const isPlaceholderDate = item.scheduled_time && + new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000); + + const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>( + item.scheduled_time && !isPlaceholderDate ? 'scheduled' : 'asap' + ); + const [scheduledTime, setScheduledTime] = useState(() => { + if (item.scheduled_time && !isPlaceholderDate) { + // Convert ISO to local datetime-local format + const date = new Date(item.scheduled_time); + return date.toISOString().slice(0, 16); + } + return ''; + }); + const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success); + const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after); + + const { data: printers } = useQuery({ + queryKey: ['printers'], + queryFn: () => api.getPrinters(), + }); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const updateMutation = useMutation({ + mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(item.id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['queue'] }); + showToast('Queue item updated'); + onClose(); + }, + onError: (error: Error) => { + showToast(error.message || 'Failed to update queue item', 'error'); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const data: PrintQueueItemUpdate = { + printer_id: printerId, + require_previous_success: requirePreviousSuccess, + auto_off_after: autoOffAfter, + }; + + if (scheduleType === 'scheduled' && scheduledTime) { + data.scheduled_time = new Date(scheduledTime).toISOString(); + } else { + data.scheduled_time = null; + } + + updateMutation.mutate(data); + }; + + // Get minimum datetime (now + 1 minute) + const getMinDateTime = () => { + const now = new Date(); + now.setMinutes(now.getMinutes() + 1); + return now.toISOString().slice(0, 16); + }; + + return ( +
+ e.stopPropagation()}> + + {/* Header */} +
+
+ +

Edit Queue Item

+
+ +
+ + {/* Form */} +
+ {/* Archive name */} +
+ +

+ {item.archive_name || `Archive #${item.archive_id}`} +

+
+ + {/* Printer selection */} +
+ + {printers?.length === 0 ? ( +
+ + No printers configured +
+ ) : ( + + )} +
+ + {/* Schedule type */} +
+ +
+ + +
+
+ + {/* Scheduled time input */} + {scheduleType === 'scheduled' && ( +
+ + setScheduledTime(e.target.value)} + min={getMinDateTime()} + required + /> +
+ )} + + {/* Require previous success */} +
+ setRequirePreviousSuccess(e.target.checked)} + className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green" + /> + +
+ + {/* Auto power off */} +
+ setAutoOffAfter(e.target.checked)} + className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green" + /> + +
+ + {/* Help text */} +

+ {scheduleType === 'asap' + ? 'Print will start as soon as the printer is idle.' + : 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'} +

+ + {/* Actions */} +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/KProfilesView.tsx b/frontend/src/components/KProfilesView.tsx index 8038a4668..8e1842f49 100644 --- a/frontend/src/components/KProfilesView.tsx +++ b/frontend/src/components/KProfilesView.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; import { Gauge, Loader2, @@ -11,6 +11,12 @@ import { WifiOff, Trash2, Search, + Copy, + Download, + Upload, + CheckSquare, + Square, + StickyNote, } from 'lucide-react'; import { api } from '../api/client'; import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client'; @@ -21,6 +27,11 @@ import { useToast } from '../contexts/ToastContext'; interface KProfileCardProps { profile: KProfile; onEdit: () => void; + onCopy?: () => void; + selectionMode?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + note?: string; // Note text to display as preview } // Truncate to 3 decimal places (like Bambu Studio) instead of rounding @@ -64,33 +75,71 @@ const extractFilamentName = (profileName: string) => { return profileName; }; -function KProfileCard({ profile, onEdit }: KProfileCardProps) { +function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: 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 || ''); + + const handleClick = () => { + if (selectionMode && onToggleSelect) { + onToggleSelect(); + } else { + onEdit(); + } + }; return ( - +
+ {selectionMode && ( + + )} + + {!selectionMode && onCopy && ( + + )} +
); } @@ -100,8 +149,11 @@ interface KProfileModalProps { nozzleDiameter: string; existingProfiles?: KProfile[]; // Existing profiles for filament selection isDualNozzle?: boolean; // Whether this is a dual-nozzle printer + initialNote?: string; // Initial note value for the profile + initialNoteKey?: string | null; // Key the note was stored under (for clearing) onClose: () => void; onSave: () => void; + onSaveNote?: (settingId: string, note: string) => void; // Callback to save note } function KProfileModal({ @@ -110,11 +162,13 @@ function KProfileModal({ nozzleDiameter, existingProfiles = [], isDualNozzle = false, + initialNote = '', + initialNoteKey = null, onClose, onSave, + onSaveNote, }: KProfileModalProps) { const { showToast } = useToast(); - const queryClient = useQueryClient(); const [name, setName] = useState(profile?.name || ''); const [kValue, setKValue] = useState( @@ -135,6 +189,7 @@ function KProfileModal({ ); const [isSyncing, setIsSyncing] = useState(false); const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 }); + const [note, setNote] = useState(initialNote); // Extract unique filaments from existing K-profiles on the printer // These have valid filament_ids that the printer recognizes @@ -162,12 +217,26 @@ function KProfileModal({ onSuccess: (result) => { console.log('[KProfile] Save success:', result); showToast('K-profile saved'); + // Save note if it changed (including clearing it) + if (onSaveNote && note !== initialNote) { + let profileKey: string; + if (note === '' && initialNoteKey) { + // Clearing note: use the same key it was stored under + profileKey = initialNoteKey; + } else if (profile && profile.slot_id > 0) { + // Editing: use setting_id if available, or composite key with slot_id + profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`; + } else { + // New profile: use name as key (will be matched when profile is loaded) + profileKey = `name_${name}_${filamentId}`; + } + onSaveNote(profileKey, note); + } // Show syncing indicator while printer processes the command setIsSyncing(true); - // Add delay before refreshing to give printer time to process the save - // Bambu printers can be slow to apply K-profile changes + // Add delay before closing to give printer time to process the save + // onSave will trigger refetch in the parent component setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] }); setIsSyncing(false); onSave(); }, 2500); @@ -189,13 +258,12 @@ function KProfileModal({ showToast('K-profile deleted'); // Show syncing indicator while printer processes the command setIsSyncing(true); - // Add delay before refreshing to give printer time to process the delete - // Bambu printers can be slow to apply K-profile changes + // Add longer delay for delete - printer needs more time to process + // before it can return the updated profile list setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] }); setIsSyncing(false); onClose(); - }, 2500); + }, 4000); }, onError: (error: Error) => { console.error('[KProfile] Delete error:', error); @@ -249,49 +317,48 @@ function KProfileModal({ return; } - // For new profiles with multiple extruders: save sequentially + // For new profiles with multiple extruders: use batch endpoint setIsSyncing(true); - setSavingProgress({ current: 0, total: selectedExtruders.length }); + setSavingProgress({ current: 1, total: selectedExtruders.length }); - for (let i = 0; i < selectedExtruders.length; i++) { - const extruderId = selectedExtruders[i]; - const payload = { - name: name, - k_value: formattedKValue, - filament_id: filamentId, - nozzle_id: nozzleId, - nozzle_diameter: modalDiameter, - extruder_id: extruderId, - setting_id: undefined, - slot_id: 0, - }; + // Build payload for all selected extruders + const batchPayload = selectedExtruders.map(extruderId => ({ + name: name, + k_value: formattedKValue, + filament_id: filamentId, + nozzle_id: nozzleId, + nozzle_diameter: modalDiameter, + extruder_id: extruderId, + setting_id: undefined, + slot_id: 0, + })); - setSavingProgress({ current: i + 1, total: selectedExtruders.length }); - console.log(`[KProfile] Saving profile ${i + 1}/${selectedExtruders.length} for extruder ${extruderId}:`, payload); + console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload); - try { - await api.setKProfile(printerId, payload); - // Wait between saves to let printer process - if (i < selectedExtruders.length - 1) { - await new Promise(resolve => setTimeout(resolve, 1500)); - } - } catch (error) { - console.error(`[KProfile] Failed to save for extruder ${extruderId}:`, error); - showToast(`Failed to save for ${extruderId === 1 ? 'Left' : 'Right'} extruder`, 'error'); - setIsSyncing(false); - setSavingProgress({ current: 0, total: 0 }); - return; + try { + await api.setKProfilesBatch(printerId, batchPayload); + showToast(`K-profile saved to ${selectedExtruders.length} extruders`); + // Save note for new batch profiles + if (onSaveNote && note) { + const profileKey = `name_${name}_${filamentId}`; + onSaveNote(profileKey, note); } + } catch (error) { + console.error('[KProfile] Failed to save batch:', error); + showToast('Failed to save K-profiles', 'error'); + setIsSyncing(false); + setSavingProgress({ current: 0, total: 0 }); + return; } - showToast(`K-profile saved to ${selectedExtruders.length} extruders`); + setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length }); // Wait for final sync before closing + // onSave will trigger refetch in the parent component setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] }); setIsSyncing(false); setSavingProgress({ current: 0, total: 0 }); onSave(); - }, 2500); + }, 3000); }; return ( @@ -425,7 +492,7 @@ function KProfileModal({ if (!profile && filamentId && !name) { const selectedFilament = knownFilaments.find(f => f.id === filamentId); if (selectedFilament) { - const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S'; + const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S'; setName(`${flowLabel} ${selectedFilament.name}`); } } @@ -502,6 +569,21 @@ function KProfileModal({ )} + {/* Notes */} +
+ +