diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb2971f5..9ad47fee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.6b10] - 2026-01-20 + +### New Features +- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette: + - 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray) + - Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream) + - Click "-" to collapse back to basic colors + +### Fixed +- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration: + - Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null + - User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly +- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots: + - Added WebSocket broadcast to AMS change callback for instant UI updates + - Removed unnecessary delayed refetch that was causing slow updates + ## [0.1.6b9] - 2026-01-19 ### New Features diff --git a/Dockerfile b/Dockerfile index 9df2a7238..580224b0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,13 +43,15 @@ RUN mkdir -p /app/data /app/logs ENV PYTHONUNBUFFERED=1 ENV DATA_DIR=/app/data ENV LOG_DIR=/app/logs +ENV PORT=8000 EXPOSE 8000 -# Health check +# Health check (uses PORT env var via shell) HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + CMD python -c "import urllib.request, os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\", \"8000\")}/health')" || exit 1 # Run the application # Use standard asyncio loop (uvloop has permission issues in some Docker environments) -CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--loop", "asyncio"] +# Port is configurable via PORT environment variable (default: 8000) +CMD sh -c "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio" diff --git a/README.md b/README.md index bd6e93ccb..270cb8e9f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ - Resizable printer cards (S/M/L/XL) - Skip objects during print - AMS slot RFID re-read +- AMS slot configuration (custom presets, K profiles, color picker) - HMS error monitoring with history - Print success rates & trends - Filament usage tracking @@ -287,6 +288,8 @@ Open **http://localhost:8000** in your browser. > **macOS/Windows users:** Docker Desktop doesn't support `network_mode: host`. Edit docker-compose.yml: comment out `network_mode: host` and uncomment the `ports:` section. Printer discovery won't work - add printers manually by IP. +> **Linux users:** If you get "permission denied" errors, either prefix commands with `sudo` (e.g., `sudo docker compose up -d`) or [add your user to the docker group](https://docs.docker.com/engine/install/linux-postinstall/). +
Docker Configuration & Commands @@ -295,6 +298,7 @@ Open **http://localhost:8000** in your browser. | Variable | Default | Description | |----------|---------|-------------| | `TZ` | `UTC` | Your timezone (e.g., `America/New_York`, `Europe/Berlin`) | +| `PORT` | `8000` | Port BamBuddy runs on (with host networking mode) | | `DEBUG` | `false` | Enable debug logging | | `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` | diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 49470c688..be73f3923 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -2010,14 +2010,39 @@ async def get_archive_plates( plate_indices.sort() + # Parse model_settings.config for plate names + # Plate names are stored with plater_id and plater_name keys + plate_names = {} # plater_id -> name + if "Metadata/model_settings.config" in namelist: + try: + model_content = zf.read("Metadata/model_settings.config").decode() + model_root = ET.fromstring(model_content) + for plate_elem in model_root.findall(".//plate"): + plater_id = None + plater_name = None + for meta in plate_elem.findall("metadata"): + key = meta.get("key") + value = meta.get("value") + if key == "plater_id" and value: + try: + plater_id = int(value) + except ValueError: + pass + elif key == "plater_name" and value: + plater_name = value.strip() + if plater_id is not None and plater_name: + plate_names[plater_id] = plater_name + except Exception: + pass # model_settings.config parsing is optional + # Parse slice_info.config for plate metadata - plate_metadata = {} # plate_index -> {filaments, prediction, weight, name} + plate_metadata = {} # plate_index -> {filaments, prediction, weight, name, objects} if "Metadata/slice_info.config" in namelist: content = zf.read("Metadata/slice_info.config").decode() root = ET.fromstring(content) for plate_elem in root.findall(".//plate"): - plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None} + plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []} # Get plate index from metadata plate_index = None @@ -2067,12 +2092,23 @@ async def get_archive_plates( # Sort filaments by slot ID plate_info["filaments"].sort(key=lambda x: x["slot_id"]) - # Get first object name as plate name hint - first_obj = plate_elem.find("object") - if first_obj is not None: - plate_info["name"] = first_obj.get("name") + # Collect all object names on this plate + for obj_elem in plate_elem.findall("object"): + obj_name = obj_elem.get("name") + if obj_name and obj_name not in plate_info["objects"]: + plate_info["objects"].append(obj_name) + # Set plate name: prefer custom name from model_settings.config, + # fall back to first object name if no custom name was set if plate_index is not None: + custom_name = plate_names.get(plate_index) + if custom_name: + plate_info["name"] = custom_name + else: + # Fall back to first object name as hint + if plate_info["objects"]: + plate_info["name"] = plate_info["objects"][0] + plate_metadata[plate_index] = plate_info # Build plate list @@ -2084,6 +2120,7 @@ async def get_archive_plates( { "index": idx, "name": meta.get("name"), + "objects": meta.get("objects", []), "has_thumbnail": has_thumbnail, "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}" if has_thumbnail diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 0f0663e9d..df5c3d7e7 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -1067,6 +1067,169 @@ async def delete_slot_preset( return {"success": True} +@router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure") +async def configure_ams_slot( + printer_id: int, + ams_id: int, + tray_id: int, + tray_info_idx: str = Query(...), + tray_type: str = Query(...), + tray_sub_brands: str = Query(...), + tray_color: str = Query(...), + nozzle_temp_min: int = Query(...), + nozzle_temp_max: int = Query(...), + cali_idx: int = Query(-1), + nozzle_diameter: str = Query("0.4"), + setting_id: str = Query(""), + kprofile_filament_id: str = Query(""), + kprofile_setting_id: str = Query(""), + k_value: float = Query(0.0), +): + """Configure an AMS slot with a specific filament setting and K profile. + + This sends two commands to the printer: + 1. ams_filament_setting - sets filament type, color, temperature + 2. extrusion_cali_sel - sets the K profile (pressure advance value) + + Args: + printer_id: Database ID of the printer + ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS) + tray_id: Tray ID within the AMS (0-3) + tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID + tray_type: Filament type (e.g., "PLA", "PETG") + tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF") + tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF") + nozzle_temp_min: Minimum nozzle temperature + nozzle_temp_max: Maximum nozzle temperature + cali_idx: K profile calibration index (-1 for default 0.020) + nozzle_diameter: Nozzle diameter string (e.g., "0.4") + setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional + kprofile_filament_id: K profile's filament_id for proper K profile linking + k_value: Direct K value to set (0.0 to skip direct K value setting) + """ + import logging + + logger = logging.getLogger(__name__) + logger.info(f"[configure_ams_slot] printer_id={printer_id}, ams_id={ams_id}, tray_id={tray_id}") + logger.info( + f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}" + ) + logger.info( + f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}" + ) + + # Get MQTT client for this printer + client = printer_manager.get_client(printer_id) + if not client: + raise HTTPException(status_code=400, detail="Printer not connected") + + # Send the filament setting command (type, color, temp) + success = client.ams_set_filament_setting( + ams_id=ams_id, + tray_id=tray_id, + tray_info_idx=tray_info_idx, + tray_type=tray_type, + tray_sub_brands=tray_sub_brands, + tray_color=tray_color, + nozzle_temp_min=nozzle_temp_min, + nozzle_temp_max=nozzle_temp_max, + setting_id=setting_id, + ) + + if not success: + raise HTTPException(status_code=500, detail="Failed to send filament configuration command") + + # Send the calibration/K-profile commands + # Use the K profile's filament_id if provided, otherwise use tray_info_idx + filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx + + # Method 1: Select existing calibration profile by cali_idx + # IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id) + # Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer + client.extrusion_cali_sel( + ams_id=ams_id, + tray_id=tray_id, + cali_idx=cali_idx, + filament_id=filament_id_for_kprofile, + nozzle_diameter=nozzle_diameter, + setting_id=kprofile_setting_id if kprofile_setting_id else None, + ) + + # Method 2: Also directly set the K value if provided (for better compatibility) + if k_value > 0: + # Calculate global tray ID for extrusion_cali_set + if ams_id <= 3: + global_tray_id = ams_id * 4 + tray_id + elif ams_id >= 128 and ams_id <= 135: + global_tray_id = (ams_id - 128) * 4 + tray_id + else: + global_tray_id = tray_id + + client.extrusion_cali_set( + tray_id=global_tray_id, + k_value=k_value, + n_coef=0.0, + nozzle_diameter=nozzle_diameter, + bed_temp=60, + nozzle_temp=nozzle_temp_max, + max_volumetric_speed=20.0, + ) + + # Request fresh status push from printer so frontend gets updated data via WebSocket + logger.info("[configure_ams_slot] Requesting status update from printer") + update_result = client.request_status_update() + logger.info(f"[configure_ams_slot] Status update request result: {update_result}") + + return { + "success": True, + "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}", + } + + +@router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset") +async def reset_ams_slot( + printer_id: int, + ams_id: int, + tray_id: int, + db: AsyncSession = Depends(get_db), +): + """Reset an AMS slot to empty/unconfigured state. + + This clears the filament configuration from the slot. + """ + # Get MQTT client for this printer + client = printer_manager.get_client(printer_id) + if not client: + raise HTTPException(status_code=400, detail="Printer not connected") + + # Reset the slot + success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id) + + if not success: + raise HTTPException(status_code=500, detail="Failed to send reset command") + + # Also delete any saved slot preset mapping + result = await db.execute( + select(SlotPresetMapping).where( + SlotPresetMapping.printer_id == printer_id, + SlotPresetMapping.ams_id == ams_id, + SlotPresetMapping.tray_id == tray_id, + ) + ) + mapping = result.scalar_one_or_none() + if mapping: + await db.delete(mapping) + await db.commit() + + # Request fresh status push from printer so frontend gets updated data via WebSocket + client.request_status_update() + + return { + "success": True, + "message": f"Reset AMS {ams_id} tray {tray_id}", + } + + @router.post("/{printer_id}/debug/simulate-print-complete") async def debug_simulate_print_complete( printer_id: int, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 924b46302..2f9aaf6aa 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,7 +5,7 @@ from pathlib import Path from pydantic_settings import BaseSettings # Application version - single source of truth -APP_VERSION = "0.1.6-final" +APP_VERSION = "0.1.6b10" GITHUB_REPO = "maziggy/bambuddy" # App directory - where the application is installed (for static files) diff --git a/backend/app/main.py b/backend/app/main.py index c4da978f5..98b5cc9d3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -280,6 +280,19 @@ async def on_ams_change(printer_id: int, ams_data: list): except Exception: pass # Don't fail AMS callback if MQTT fails + # Broadcast AMS change via WebSocket (bypasses status_key deduplication) + # This ensures frontend gets immediate updates when AMS slots are configured + try: + state = printer_manager.get_status(printer_id) + if state: + logger.info(f"[Printer {printer_id}] Broadcasting AMS change via WebSocket") + await ws_manager.send_printer_status( + printer_id, + printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)), + ) + except Exception as e: + logger.warning(f"Failed to broadcast AMS change for printer {printer_id}: {e}") + try: async with async_session() as db: from backend.app.api.routes.settings import get_setting diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index f917bcade..713db02ef 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -358,6 +358,7 @@ class BambuMQTTClient: # Track last message time - receiving a message proves we're connected self._last_message_time = time.time() self.state.connected = True + # TEMP: Dump full payload once to find extruder state field if not hasattr(self, "_payload_dumped"): self._payload_dumped = True @@ -1858,7 +1859,9 @@ class BambuMQTTClient: True if the request was sent, False if not connected. """ if not self._client or not self.state.connected: + logger.warning(f"[{self.serial_number}] request_status_update: not connected") return False + logger.info(f"[{self.serial_number}] Requesting status update (pushall)") self._request_push_all() # Note: get_accessories returns stale nozzle data on H2D. # The correct nozzle data comes from push_status response. @@ -3155,20 +3158,22 @@ class BambuMQTTClient: tray_color: str, nozzle_temp_min: int, nozzle_temp_max: int, - k: float, + setting_id: str = "", ) -> bool: - """Set AMS tray filament settings including K (pressure advance) value. + """Set AMS tray filament settings (type, color, temperature). + + Note: K value is set separately via extrusion_cali_sel command. Args: - ams_id: AMS unit ID (0-3) + ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS) tray_id: Tray ID within the AMS (0-3) - tray_info_idx: Filament preset ID (e.g., "GFA00") + tray_info_idx: Filament ID short format (e.g., "GFL05") tray_type: Filament type (e.g., "PLA", "PETG") tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF") tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF") nozzle_temp_min: Minimum nozzle temperature nozzle_temp_max: Maximum nozzle temperature - k: Pressure advance (K) value (e.g., 0.020) + setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional Returns: True if command was sent, False otherwise @@ -3177,25 +3182,194 @@ class BambuMQTTClient: logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected") return False + # Calculate slot_id based on AMS type + if ams_id <= 3: + slot_id = tray_id + else: + # AMS-HT or external: slot_id = 0 + slot_id = 0 + command = { "print": { "command": "ams_filament_setting", "ams_id": ams_id, "tray_id": tray_id, + "slot_id": slot_id, "tray_info_idx": tray_info_idx, "tray_type": tray_type, "tray_sub_brands": tray_sub_brands, "tray_color": tray_color, "nozzle_temp_min": nozzle_temp_min, "nozzle_temp_max": nozzle_temp_max, - "k": k, + "sequence_id": "0", + } + } + + # Include setting_id if provided (helps slicer show correct profile) + if setting_id: + command["print"]["setting_id"] = setting_id + + command_json = json.dumps(command) + logger.info( + f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, tray_info_idx={tray_info_idx}, setting_id={setting_id}" + ) + logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}") + self._client.publish(self.topic_publish, command_json, qos=1) + return True + + def reset_ams_slot(self, ams_id: int, tray_id: int) -> bool: + """Reset an AMS slot to empty/unconfigured state. + + Args: + ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS) + tray_id: Tray ID within the AMS (0-3) + + Returns: + True if command was sent, False otherwise + """ + if not self._client or not self.state.connected: + logger.warning(f"[{self.serial_number}] Cannot reset AMS slot: not connected") + return False + + # Calculate slot_id based on AMS type + if ams_id <= 3: + slot_id = tray_id + else: + slot_id = 0 + + command = { + "print": { + "command": "ams_filament_setting", + "ams_id": ams_id, + "tray_id": tray_id, + "slot_id": slot_id, + "tray_info_idx": "", + "tray_type": "", + "tray_sub_brands": "", + "tray_color": "00000000", + "nozzle_temp_min": 0, + "nozzle_temp_max": 0, "sequence_id": "0", } } 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}") + logger.info(f"[{self.serial_number}] Resetting AMS slot: AMS {ams_id}, tray {tray_id}") + logger.debug(f"[{self.serial_number}] reset_ams_slot command: {command_json}") + self._client.publish(self.topic_publish, command_json, qos=1) + return True + + def extrusion_cali_sel( + self, + ams_id: int, + tray_id: int, + cali_idx: int, + filament_id: str, + nozzle_diameter: str = "0.4", + setting_id: str | None = None, + ) -> bool: + """Set calibration profile (K value) for an AMS slot. + + This command selects a K profile from the printer's calibration list. + Use cali_idx=-1 to use the default K value (0.020). + + Args: + ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS) + tray_id: Tray ID within the AMS (0-3) + cali_idx: Calibration profile index (-1 for default) + filament_id: Filament preset ID (same as tray_info_idx) + nozzle_diameter: Nozzle diameter string (e.g., "0.4") + setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional + + 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 calibration: not connected") + return False + + # Calculate slot_id based on AMS type + # tray_id in the command should be the local tray index (0-3) + if ams_id <= 3: + slot_id = tray_id + elif ams_id >= 128 and ams_id <= 135: + slot_id = 0 + else: + slot_id = 0 + + command = { + "print": { + "command": "extrusion_cali_sel", + "cali_idx": cali_idx, + "filament_id": filament_id, + "nozzle_diameter": nozzle_diameter, + "ams_id": ams_id, + "tray_id": tray_id, # Local tray index (0-3), not global + "slot_id": slot_id, + "sequence_id": "0", + } + } + + # Include setting_id if provided (helps slicer show correct K profile) + if setting_id: + command["print"]["setting_id"] = setting_id + + command_json = json.dumps(command) + logger.info( + f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}" + ) + logger.debug(f"[{self.serial_number}] extrusion_cali_sel command: {command_json}") + self._client.publish(self.topic_publish, command_json, qos=1) + return True + + def extrusion_cali_set( + self, + tray_id: int, + k_value: float, + n_coef: float = 0.0, + nozzle_diameter: str = "0.4", + bed_temp: int = 60, + nozzle_temp: int = 220, + max_volumetric_speed: float = 20.0, + ) -> bool: + """Directly set K value (pressure advance) for a tray. + + This command sets the K value directly without selecting from stored profiles. + Use this when you want to apply a specific K value to a tray. + + Args: + tray_id: Global tray ID (ams_id * 4 + slot) + k_value: Pressure advance K value (e.g., 0.020) + n_coef: N coefficient (usually 0.0 for manual, 1.4 for auto-calibration) + nozzle_diameter: Nozzle diameter string (e.g., "0.4") + bed_temp: Bed temperature for calibration reference + nozzle_temp: Nozzle temperature for calibration reference + max_volumetric_speed: Max volumetric speed for calibration reference + + 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 value: not connected") + return False + + command = { + "print": { + "command": "extrusion_cali_set", + "tray_id": tray_id, + "k_value": k_value, + "n_coef": n_coef, + "nozzle_diameter": nozzle_diameter, + "bed_temp": bed_temp, + "nozzle_temp": nozzle_temp, + "max_volumetric_speed": max_volumetric_speed, + "sequence_id": "0", + } + } + + command_json = json.dumps(command) + logger.info(f"[{self.serial_number}] Publishing extrusion_cali_set: tray {tray_id}, k_value={k_value}") + logger.debug(f"[{self.serial_number}] extrusion_cali_set command: {command_json}") self._client.publish(self.topic_publish, command_json, qos=1) return True diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 687af06c4..d0a2160e5 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -916,3 +916,34 @@ class TestInitPrinterConnections: await init_printer_connections(mock_db) mock_manager.connect_printer.assert_not_called() + + +class TestAmsChangeCallback: + """Tests for AMS change callback functionality.""" + + @pytest.fixture + def manager(self): + """Create a fresh PrinterManager instance.""" + return PrinterManager() + + def test_ams_change_callback_is_triggered(self, manager): + """Verify AMS change callback is called when AMS data changes.""" + callback = MagicMock() + manager.set_ams_change_callback(callback) + + # Verify callback was set + assert manager._on_ams_change == callback + + def test_ams_change_callback_receives_correct_data(self, manager): + """Verify AMS change callback receives the correct AMS data format.""" + received_data = [] + + def capture_callback(printer_id, ams_data): + received_data.append((printer_id, ams_data)) + + manager.set_ams_change_callback(capture_callback) + + # The callback should accept printer_id and ams_data + # This tests the callback signature + assert manager._on_ams_change is not None + assert callable(manager._on_ams_change) diff --git a/docker-compose.yml b/docker-compose.yml index 3c6f0fe24..b82c50d5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: # Comment out "network_mode: host" above and uncomment "ports:" below. # Note: Printer discovery won't work - add printers manually by IP. #ports: - # - "8000:8000" + # - "${PORT:-8000}:8000" volumes: - bambuddy_data:/app/data - bambuddy_logs:/app/logs @@ -24,6 +24,9 @@ services: - ./virtual_printer:/app/data/virtual_printer environment: - TZ=Europe/Berlin + # Port BamBuddy runs on (default: 8000) + # Usage: PORT=8080 docker compose up -d + - PORT=${PORT:-8000} restart: unless-stopped volumes: diff --git a/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx b/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx new file mode 100644 index 000000000..a22d56673 --- /dev/null +++ b/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx @@ -0,0 +1,207 @@ +/** + * Tests for the ConfigureAmsSlotModal component. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../utils'; +import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal'; +import { api } from '../../api/client'; + +// Mock the API client +vi.mock('../../api/client', () => ({ + api: { + getCloudSettings: vi.fn(), + getKProfiles: vi.fn(), + configureAmsSlot: vi.fn(), + getCloudSettingDetail: vi.fn(), + saveSlotPreset: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), + }, +})); + +const mockCloudSettings = { + filament: [ + { + setting_id: 'GFSL05_09', + name: 'Bambu PLA Basic @BBL X1C', + filament_id: 'GFL05', + }, + { + setting_id: 'PFUScd84f663d2c2ef', + name: '# Overture Matte PLA @BBL H2D', + filament_id: null, + }, + ], +}; + +const mockKProfiles = { + profiles: [ + { + id: 1, + name: 'PLA Basic', + k_value: '0.020', + filament_id: 'GFL05', + setting_id: '', + extruder_id: 1, + cali_idx: 1, + }, + ], +}; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + printerId: 1, + slotInfo: { + amsId: 0, + trayId: 0, + trayCount: 4, + trayType: 'PLA', + trayColor: 'FFFFFF', + traySubBrands: 'PLA Basic', + }, + nozzleDiameter: '0.4', + onSuccess: vi.fn(), +}; + +describe('ConfigureAmsSlotModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + (api.getCloudSettings as ReturnType).mockResolvedValue(mockCloudSettings); + (api.getKProfiles as ReturnType).mockResolvedValue(mockKProfiles); + (api.configureAmsSlot as ReturnType).mockResolvedValue({ success: true }); + (api.saveSlotPreset as ReturnType).mockResolvedValue({ success: true }); + }); + + it('renders nothing visible when closed', () => { + render(); + expect(screen.queryByText('Configure AMS Slot')).not.toBeInTheDocument(); + }); + + it('renders modal when open', async () => { + render(); + await waitFor(() => { + expect(screen.getByText(/Configure AMS/)).toBeInTheDocument(); + }); + }); + + it('displays basic color buttons', async () => { + render(); + await waitFor(() => { + // Check for basic color buttons by their title attribute + expect(screen.getByTitle('White')).toBeInTheDocument(); + expect(screen.getByTitle('Black')).toBeInTheDocument(); + expect(screen.getByTitle('Red')).toBeInTheDocument(); + expect(screen.getByTitle('Blue')).toBeInTheDocument(); + expect(screen.getByTitle('Green')).toBeInTheDocument(); + expect(screen.getByTitle('Yellow')).toBeInTheDocument(); + expect(screen.getByTitle('Orange')).toBeInTheDocument(); + expect(screen.getByTitle('Gray')).toBeInTheDocument(); + }); + }); + + it('does not show extended colors by default', async () => { + render(); + await waitFor(() => { + expect(screen.getByTitle('White')).toBeInTheDocument(); + }); + // Extended colors should not be visible initially + expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument(); + expect(screen.queryByTitle('Purple')).not.toBeInTheDocument(); + expect(screen.queryByTitle('Coral')).not.toBeInTheDocument(); + }); + + it('shows extended colors when expand button is clicked', async () => { + render(); + await waitFor(() => { + expect(screen.getByTitle('White')).toBeInTheDocument(); + }); + + // Click the expand button (+ button) + const expandButton = screen.getByTitle('Show more colors'); + fireEvent.click(expandButton); + + // Extended colors should now be visible + await waitFor(() => { + expect(screen.getByTitle('Cyan')).toBeInTheDocument(); + expect(screen.getByTitle('Purple')).toBeInTheDocument(); + expect(screen.getByTitle('Pink')).toBeInTheDocument(); + expect(screen.getByTitle('Brown')).toBeInTheDocument(); + expect(screen.getByTitle('Coral')).toBeInTheDocument(); + }); + }); + + it('hides extended colors when collapse button is clicked', async () => { + render(); + await waitFor(() => { + expect(screen.getByTitle('White')).toBeInTheDocument(); + }); + + // Click the expand button + const expandButton = screen.getByTitle('Show more colors'); + fireEvent.click(expandButton); + + // Wait for extended colors to appear + await waitFor(() => { + expect(screen.getByTitle('Cyan')).toBeInTheDocument(); + }); + + // Click the collapse button + const collapseButton = screen.getByTitle('Show less colors'); + fireEvent.click(collapseButton); + + // Extended colors should be hidden again + await waitFor(() => { + expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument(); + }); + }); + + it('selects a color when color button is clicked', async () => { + render(); + await waitFor(() => { + expect(screen.getByTitle('Red')).toBeInTheDocument(); + }); + + // Click the red color button + const redButton = screen.getByTitle('Red'); + fireEvent.click(redButton); + + // The color input should now show "Red" + const colorInput = screen.getByPlaceholderText(/Color name or hex/); + expect(colorInput).toHaveValue('Red'); + }); + + it('derives tray_info_idx from base_id when filament_id is null', async () => { + // Mock the detail API to return base_id but no filament_id + (api.getCloudSettingDetail as ReturnType).mockResolvedValue({ + filament_id: null, + base_id: 'GFSL05_09', + name: '# Overture Matte PLA @BBL H2D', + }); + + render(); + + // Wait for presets to load + await waitFor(() => { + expect(api.getCloudSettings).toHaveBeenCalled(); + }); + + // Select a user preset (one without filament_id) + // Find and click the preset - this would require the preset to be in the list + // The actual tray_info_idx derivation happens during the configure mutation + }); + + it('renders configure slot button', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/Configure AMS/)).toBeInTheDocument(); + }); + + // Find the Configure Slot button + const configureButton = screen.getByRole('button', { name: /Configure Slot/i }); + expect(configureButton).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 22eb5c7b7..48f0cab78 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1814,6 +1814,7 @@ export const api = { plates: Array<{ index: number; name: string | null; + objects: string[]; has_thumbnail: boolean; thumbnail_url: string | null; print_time_seconds: number | null; @@ -2139,6 +2140,57 @@ export const api = { request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, { method: 'DELETE', }), + configureAmsSlot: ( + printerId: number, + amsId: number, + trayId: number, + config: { + tray_info_idx: string; + tray_type: string; + tray_sub_brands: string; + tray_color: string; + nozzle_temp_min: number; + nozzle_temp_max: number; + cali_idx: number; + nozzle_diameter: string; + setting_id?: string; + kprofile_filament_id?: string; + kprofile_setting_id?: string; + k_value?: number; + } + ) => { + const params = new URLSearchParams({ + tray_info_idx: config.tray_info_idx, + tray_type: config.tray_type, + tray_sub_brands: config.tray_sub_brands, + tray_color: config.tray_color, + nozzle_temp_min: config.nozzle_temp_min.toString(), + nozzle_temp_max: config.nozzle_temp_max.toString(), + cali_idx: config.cali_idx.toString(), + nozzle_diameter: config.nozzle_diameter, + }); + if (config.setting_id) { + params.set('setting_id', config.setting_id); + } + if (config.kprofile_filament_id) { + params.set('kprofile_filament_id', config.kprofile_filament_id); + } + if (config.kprofile_setting_id) { + params.set('kprofile_setting_id', config.kprofile_setting_id); + } + if (config.k_value !== undefined && config.k_value > 0) { + params.set('k_value', config.k_value.toString()); + } + return request<{ success: boolean; message: string }>( + `/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`, + { method: 'POST' } + ); + }, + resetAmsSlot: (printerId: number, amsId: number, trayId: number) => + request<{ success: boolean; message: string }>( + `/printers/${printerId}/ams/${amsId}/tray/${trayId}/reset`, + { method: 'POST' } + ), // Filaments listFilaments: () => request('/filaments/'), diff --git a/frontend/src/components/ConfigureAmsSlotModal.tsx b/frontend/src/components/ConfigureAmsSlotModal.tsx new file mode 100644 index 000000000..64d04407a --- /dev/null +++ b/frontend/src/components/ConfigureAmsSlotModal.tsx @@ -0,0 +1,853 @@ +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react'; +import { api } from '../api/client'; +import type { KProfile } from '../api/client'; +import { Button } from './Button'; + +interface SlotInfo { + amsId: number; + trayId: number; + trayCount: number; + trayType?: string; + trayColor?: string; + traySubBrands?: string; + trayInfoIdx?: string; +} + +// Get proper AMS label (handles HT AMS with ID 128+) +function getAmsLabel(amsId: number, trayCount: number): string { + // External spool + if (amsId === 255) return 'External'; + + let normalizedId: number; + let isHt = false; + + if (amsId >= 128 && amsId <= 135) { + // HT AMS range: 128-135 → A-H + normalizedId = amsId - 128; + isHt = true; + } else if (amsId >= 0 && amsId <= 3) { + // Regular AMS range: 0-3 → A-D + normalizedId = amsId; + // Check tray count as secondary indicator + isHt = trayCount === 1; + } else { + // Unknown range - fallback to A + normalizedId = 0; + } + + // Cap to valid letter range (A-H) + normalizedId = Math.max(0, Math.min(normalizedId, 7)); + const letter = String.fromCharCode(65 + normalizedId); + + return isHt ? `HT-${letter}` : `AMS-${letter}`; +} + +// Convert setting_id to tray_info_idx (filament_id format) +// Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05" +function convertToTrayInfoIdx(settingId: string): string { + // Strip version suffix if present (e.g., GFSL05_07 -> GFSL05) + const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId; + + // Bambu presets start with "GFS" - remove the 'S' to get filament_id + if (baseId.startsWith('GFS')) { + return 'GF' + baseId.slice(3); + } + + // User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix) + // This follows the pattern that filament_id and setting_id share the same base ID + if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) { + return baseId; // Use base ID without version suffix + } + + // For other formats, use as-is + return baseId; +} + +interface ConfigureAmsSlotModalProps { + isOpen: boolean; + onClose: () => void; + printerId: number; + slotInfo: SlotInfo; + nozzleDiameter?: string; + onSuccess?: () => void; +} + +// Known filament material types +const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET']; + +// Extract filament type from preset name by finding known material type +function parsePresetName(name: string): { material: string; brand: string; variant: string } { + // Remove printer/nozzle suffix first + const withoutSuffix = name.replace(/@.+$/, '').trim(); + + // Try to find a known material type in the name + const upperName = withoutSuffix.toUpperCase(); + for (const mat of MATERIAL_TYPES) { + // Use word boundary to match whole words only + const regex = new RegExp(`\\b${mat}\\b`, 'i'); + if (regex.test(upperName)) { + // Found material, extract brand (everything before material) and variant (after) + const parts = withoutSuffix.split(regex); + const brand = parts[0]?.trim() || ''; + const variant = parts[1]?.trim() || ''; + return { material: mat, brand, variant }; + } + } + + // Fallback: assume first word is brand, second is material + const parts = withoutSuffix.split(/\s+/); + if (parts.length >= 2) { + return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') }; + } + + return { material: withoutSuffix, brand: '', variant: '' }; +} + +// Check if a preset is a user preset (not built-in) +function isUserPreset(settingId: string): boolean { + // Built-in presets have specific patterns, user presets are UUIDs + return !settingId.startsWith('GF') && !settingId.startsWith('P1'); +} + +// Common color name to hex mapping +const COLOR_NAME_MAP: Record = { + // Basic colors + 'white': 'FFFFFF', + 'black': '000000', + 'red': 'FF0000', + 'green': '00FF00', + 'blue': '0000FF', + 'yellow': 'FFFF00', + 'cyan': '00FFFF', + 'magenta': 'FF00FF', + 'orange': 'FFA500', + 'purple': '800080', + 'pink': 'FFC0CB', + 'brown': '8B4513', + 'gray': '808080', + 'grey': '808080', + // Filament-specific colors + 'jade white': 'FFFEF2', + 'ivory': 'FFFFF0', + 'beige': 'F5F5DC', + 'cream': 'FFFDD0', + 'silver': 'C0C0C0', + 'gold': 'FFD700', + 'bronze': 'CD7F32', + 'copper': 'B87333', + 'navy': '000080', + 'teal': '008080', + 'olive': '808000', + 'maroon': '800000', + 'coral': 'FF7F50', + 'salmon': 'FA8072', + 'lime': '32CD32', + 'mint': '98FF98', + 'forest green': '228B22', + 'sky blue': '87CEEB', + 'royal blue': '4169E1', + 'turquoise': '40E0D0', + 'lavender': 'E6E6FA', + 'violet': 'EE82EE', + 'plum': 'DDA0DD', + 'tan': 'D2B48C', + 'chocolate': 'D2691E', + 'charcoal': '36454F', + 'slate': '708090', + 'transparent': '000000', // Will need special handling + 'natural': 'F5F5DC', + 'wood': 'DEB887', +}; + +// Quick-select color presets (common filament colors) +// Basic colors shown by default +const QUICK_COLORS_BASIC = [ + { name: 'White', hex: 'FFFFFF' }, + { name: 'Black', hex: '000000' }, + { name: 'Red', hex: 'FF0000' }, + { name: 'Blue', hex: '0000FF' }, + { name: 'Green', hex: '00AA00' }, + { name: 'Yellow', hex: 'FFFF00' }, + { name: 'Orange', hex: 'FFA500' }, + { name: 'Gray', hex: '808080' }, +]; + +// Extended colors shown when expanded +const QUICK_COLORS_EXTENDED = [ + { name: 'Cyan', hex: '00FFFF' }, + { name: 'Magenta', hex: 'FF00FF' }, + { name: 'Purple', hex: '800080' }, + { name: 'Pink', hex: 'FFC0CB' }, + { name: 'Brown', hex: '8B4513' }, + { name: 'Beige', hex: 'F5F5DC' }, + { name: 'Navy', hex: '000080' }, + { name: 'Teal', hex: '008080' }, + { name: 'Lime', hex: '32CD32' }, + { name: 'Gold', hex: 'FFD700' }, + { name: 'Silver', hex: 'C0C0C0' }, + { name: 'Maroon', hex: '800000' }, + { name: 'Olive', hex: '808000' }, + { name: 'Coral', hex: 'FF7F50' }, + { name: 'Salmon', hex: 'FA8072' }, + { name: 'Turquoise', hex: '40E0D0' }, + { name: 'Violet', hex: 'EE82EE' }, + { name: 'Indigo', hex: '4B0082' }, + { name: 'Chocolate', hex: 'D2691E' }, + { name: 'Tan', hex: 'D2B48C' }, + { name: 'Slate', hex: '708090' }, + { name: 'Charcoal', hex: '36454F' }, + { name: 'Ivory', hex: 'FFFFF0' }, + { name: 'Cream', hex: 'FFFDD0' }, +]; + +// Try to convert color name to hex +function colorNameToHex(name: string): string | null { + const normalized = name.toLowerCase().trim(); + return COLOR_NAME_MAP[normalized] || null; +} + +export function ConfigureAmsSlotModal({ + isOpen, + onClose, + printerId, + slotInfo, + nozzleDiameter = '0.4', + onSuccess, +}: ConfigureAmsSlotModalProps) { + const [selectedPresetId, setSelectedPresetId] = useState(''); + const [selectedKProfile, setSelectedKProfile] = useState(null); + const [colorHex, setColorHex] = useState(''); // Just the 6-char hex, no alpha + const [colorInput, setColorInput] = useState(''); // User's text input (name or hex) + const [searchQuery, setSearchQuery] = useState(''); + const [showSuccess, setShowSuccess] = useState(false); + const [showExtendedColors, setShowExtendedColors] = useState(false); + + // Fetch cloud settings + const { data: cloudSettings, isLoading: settingsLoading } = useQuery({ + queryKey: ['cloudSettings'], + queryFn: () => api.getCloudSettings(), + enabled: isOpen, + }); + + // Fetch K profiles + const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({ + queryKey: ['kprofiles', printerId, nozzleDiameter], + queryFn: () => api.getKProfiles(printerId, nozzleDiameter), + enabled: isOpen && !!printerId, + }); + + // Configure slot mutation + const configureMutation = useMutation({ + mutationFn: async () => { + if (!selectedPresetId) throw new Error('No filament preset selected'); + + // Get the selected preset details + const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId); + if (!selectedPreset) throw new Error('Selected preset not found'); + + // Parse the preset name for filament info + const parsed = parsePresetName(selectedPreset.name); + + // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020) + const caliIdx = selectedKProfile?.slot_id ?? -1; + + // Use custom color if set, otherwise use current slot color or default + const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF'; + + // Create the tray_sub_brands from preset name (without printer/nozzle suffix) + const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim(); + + // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id + let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId); + + // For user presets (not starting with GF), fetch the detail to get the real filament_id + if (!selectedPresetId.startsWith('GFS')) { + try { + const detail = await api.getCloudSettingDetail(selectedPresetId); + if (detail.filament_id) { + trayInfoIdx = detail.filament_id; + } else if (detail.base_id) { + // If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it + // This is common for user presets that inherit from Bambu presets + trayInfoIdx = convertToTrayInfoIdx(detail.base_id); + console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`); + } + } catch (e) { + console.warn('Failed to fetch preset detail for filament_id:', e); + // Fall back to derived tray_info_idx + } + } + + // Default temp range based on material type + let tempMin = 190; + let tempMax = 230; + const material = parsed.material.toUpperCase(); + if (material.includes('PLA')) { + tempMin = 190; + tempMax = 230; + } else if (material.includes('PETG')) { + tempMin = 220; + tempMax = 260; + } else if (material.includes('ABS')) { + tempMin = 240; + tempMax = 280; + } else if (material.includes('ASA')) { + tempMin = 240; + tempMax = 280; + } else if (material.includes('TPU')) { + tempMin = 200; + tempMax = 240; + } else if (material.includes('PC')) { + tempMin = 260; + tempMax = 300; + } else if (material.includes('PA') || material.includes('NYLON')) { + tempMin = 250; + tempMax = 290; + } + + // Parse K value from selected profile + const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0; + + // Configure the slot via MQTT + const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, { + tray_info_idx: trayInfoIdx, + tray_type: parsed.material || 'PLA', + tray_sub_brands: traySubBrands, + tray_color: color + 'FF', // Add alpha + nozzle_temp_min: tempMin, + nozzle_temp_max: tempMax, + cali_idx: caliIdx, + nozzle_diameter: nozzleDiameter, + setting_id: selectedPresetId, // Full setting ID for slicer compatibility + // Pass K profile's filament_id and setting_id for proper linking + kprofile_filament_id: selectedKProfile?.filament_id, + kprofile_setting_id: selectedKProfile?.setting_id || undefined, + // Also pass the K value directly for extrusion_cali_set command + k_value: kValue, + }); + + // Save the preset mapping so we can display the correct name in the UI + // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx, + // which can't be resolved to a name via the filamentInfo API + try { + await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands); + } catch (e) { + console.warn('Failed to save slot preset mapping:', e); + // Don't fail the whole operation - slot was configured successfully + } + + return result; + }, + onSuccess: () => { + setShowSuccess(true); + onSuccess?.(); + // Close after showing success briefly + setTimeout(() => { + setShowSuccess(false); + onClose(); + }, 1500); + }, + }); + + // Reset slot mutation + const resetMutation = useMutation({ + mutationFn: async () => { + return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId); + }, + onSuccess: () => { + setShowSuccess(true); + onSuccess?.(); + setTimeout(() => { + setShowSuccess(false); + onClose(); + }, 1500); + }, + }); + + // Filter filament presets based on search + const filteredPresets = useMemo(() => { + if (!cloudSettings?.filament) return []; + + const query = searchQuery.toLowerCase(); + return cloudSettings.filament + .filter(p => { + if (!query) return true; + return p.name.toLowerCase().includes(query); + }) + .sort((a, b) => { + // Sort user presets first, then alphabetically + const aIsUser = isUserPreset(a.setting_id); + const bIsUser = isUserPreset(b.setting_id); + if (aIsUser && !bIsUser) return -1; + if (!aIsUser && bIsUser) return 1; + return a.name.localeCompare(b.name); + }); + }, [cloudSettings?.filament, searchQuery]); + + // Get full preset name for K profile filtering (brand + material, without printer suffix) + const selectedPresetInfo = useMemo(() => { + if (!selectedPresetId || !cloudSettings?.filament) return null; + const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId); + if (!selectedPreset) return null; + + // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle") + let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim(); + // Strip leading "# " from custom preset names (user convention) + if (nameWithoutSuffix.startsWith('# ')) { + nameWithoutSuffix = nameWithoutSuffix.slice(2).trim(); + } + const parsed = parsePresetName(nameWithoutSuffix); + + return { + fullName: nameWithoutSuffix, + material: parsed.material, + brand: parsed.brand, + }; + }, [selectedPresetId, cloudSettings?.filament]); + + // For backwards compatibility with the label + const selectedMaterial = selectedPresetInfo?.fullName || ''; + + const matchingKProfiles = useMemo(() => { + if (!kprofilesData?.profiles || !selectedPresetInfo) return []; + + const { fullName, material, brand } = selectedPresetInfo; + const upperFullName = fullName.toUpperCase(); + const upperMaterial = material.toUpperCase(); + const upperBrand = brand.toUpperCase(); + + // Material must be at least 2 chars to avoid false positives + if (!upperMaterial || upperMaterial.length < 2) return []; + + // Filter profiles - require brand match if brand is present in selected preset + const filtered = kprofilesData.profiles.filter(p => { + const profileName = p.name.toUpperCase(); + + // If the selected preset has a brand (e.g., "Azurefilm PLA Wood"), + // only show profiles that match the brand + if (upperBrand) { + // Must contain the brand name + if (!profileName.includes(upperBrand)) { + return false; + } + // And must contain the material type + if (!profileName.includes(upperMaterial)) { + return false; + } + return true; + } + + // No brand in selected preset - match on full name or material + // Priority 1: Exact match with full name + if (profileName.includes(upperFullName)) { + return true; + } + + // Priority 2: Material type match (only when no brand specified) + if (profileName.includes(upperMaterial)) { + return true; + } + + // Check for common material aliases + const aliases: Record = { + 'NYLON': ['PA', 'PA-CF', 'PA6'], + 'PA': ['NYLON'], + }; + + const materialAliases = aliases[upperMaterial] || []; + for (const alias of materialAliases) { + if (profileName.includes(alias)) { + return true; + } + } + + return false; + }); + + // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates) + // Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D + const seen = new Map(); + for (const profile of filtered) { + const key = `${profile.name}|${profile.k_value}`; + const existing = seen.get(key); + if (!existing) { + seen.set(key, profile); + } else if (profile.extruder_id === 1 && existing.extruder_id === 0) { + // Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile + seen.set(key, profile); + } + } + return Array.from(seen.values()); + }, [kprofilesData?.profiles, selectedPresetInfo]); + + // Pre-select current profile when modal opens, reset when closes + useEffect(() => { + if (isOpen && cloudSettings?.filament) { + // Try to pre-select current profile based on trayInfoIdx + if (slotInfo.trayInfoIdx) { + const currentPreset = cloudSettings.filament.find( + p => p.setting_id === slotInfo.trayInfoIdx + ); + if (currentPreset) { + setSelectedPresetId(currentPreset.setting_id); + } + } + } else if (!isOpen) { + // Reset when modal closes + setSelectedPresetId(''); + setSelectedKProfile(null); + setColorHex(''); + setColorInput(''); + setSearchQuery(''); + setShowSuccess(false); + } + }, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]); + + // Auto-select best matching K profile when preset changes + useEffect(() => { + if (matchingKProfiles.length > 0) { + // Auto-select first matching profile + setSelectedKProfile(matchingKProfiles[0]); + } else { + setSelectedKProfile(null); + } + }, [selectedPresetId, matchingKProfiles.length]); // Only trigger when preset changes + + // Escape key handler + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }, [onClose]); + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } + }, [isOpen, handleKeyDown]); + + if (!isOpen) return null; + + const isLoading = settingsLoading || kprofilesLoading; + const canSave = selectedPresetId && !configureMutation.isPending; + + // Get display color (custom or slot default) + const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF'; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ +

Configure AMS Slot

+
+ +
+ + {/* Content */} +
+ {/* Success overlay */} + {showSuccess && ( +
+
+ +

Slot Configured!

+

Settings sent to printer

+
+
+ )} + + {/* Slot info */} +
+

Configuring slot:

+
+ {slotInfo.trayColor && ( + + )} + + {getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1} + + {slotInfo.traySubBrands && ( + ({slotInfo.traySubBrands}) + )} +
+
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + {/* Filament Profile Select */} +
+ +
+ setSearchQuery(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2" + /> +
+ {filteredPresets.length === 0 ? ( +

+ {cloudSettings?.filament?.length === 0 + ? 'No cloud presets. Login to Bambu Cloud to sync.' + : 'No matching presets found.'} +

+ ) : ( + filteredPresets.map((preset) => ( + + )) + )} +
+
+
+ + {/* K Profile Select */} +
+ + {matchingKProfiles.length > 0 ? ( +
+ + +
+ ) : selectedPresetId ? ( +

+ No matching K profiles found. Default K=0.020 will be used. +

+ ) : ( + + Select a filament profile first + + )} + {selectedKProfile && ( +

+ K={selectedKProfile.k_value} from printer calibration +

+ )} +
+ + {/* Optional: Custom color */} +
+ + {/* Quick color buttons */} +
+ {QUICK_COLORS_BASIC.map((color) => ( + +
+ {/* Extended colors (collapsible) */} + {showExtendedColors && ( +
+ {QUICK_COLORS_EXTENDED.map((color) => ( +
+ )} + {/* Color input: name or hex */} +
+
+ { + const input = e.target.value; + setColorInput(input); + + // Try to parse as color name first + const nameHex = colorNameToHex(input); + if (nameHex) { + setColorHex(nameHex); + } else { + // Try to parse as hex code + const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase(); + if (cleaned.length === 6) { + setColorHex(cleaned); + } else if (cleaned.length === 3) { + // Expand shorthand hex (e.g., F00 -> FF0000) + setColorHex(cleaned.split('').map(c => c + c).join('')); + } + } + }} + className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm" + /> + {colorHex && ( + + )} +
+ {colorHex && ( +

+ Hex: #{colorHex} +

+ )} +
+ + )} +
+ + {/* Footer */} +
+ {/* Reset button on the left */} + + {/* Cancel and Configure buttons on the right */} +
+ + +
+
+ + {/* Error */} + {(configureMutation.isError || resetMutation.isError) && ( +
+ {(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/EditQueueItemModal.tsx b/frontend/src/components/EditQueueItemModal.tsx index d03fe68b9..edf3d8305 100644 --- a/frontend/src/components/EditQueueItemModal.tsx +++ b/frontend/src/components/EditQueueItemModal.tsx @@ -462,10 +462,12 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { )}

- Plate {plate.index} + {plate.name || `Plate ${plate.index}`}

- {plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`} + {plate.objects?.length > 0 + ? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '') + : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}

{selectedPlate === plate.index && ( diff --git a/frontend/src/components/FilamentHoverCard.tsx b/frontend/src/components/FilamentHoverCard.tsx index 549efba15..92c396a72 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/frontend/src/components/FilamentHoverCard.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, type ReactNode } from 'react'; -import { Droplets, Link2, Copy, Check } from 'lucide-react'; +import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react'; interface FilamentData { vendor: 'Bambu Lab' | 'Generic'; @@ -17,19 +17,25 @@ interface SpoolmanConfig { hasUnlinkedSpools?: boolean; // Whether there are spools available to link } +interface ConfigureSlotConfig { + enabled: boolean; + onConfigure?: () => void; +} + interface FilamentHoverCardProps { data: FilamentData; children: ReactNode; disabled?: boolean; className?: string; spoolman?: SpoolmanConfig; + configureSlot?: ConfigureSlotConfig; } /** * A hover card that displays filament details when hovering over AMS slots. * Replaces the basic browser tooltip with a styled popover. */ -export function FilamentHoverCard({ data, children, disabled, className = '', spoolman }: FilamentHoverCardProps) { +export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) { const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); const [copied, setCopied] = useState(false); @@ -287,6 +293,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp )}
)} + + {/* Configure slot section - always show if enabled */} + {configureSlot?.enabled && ( +
+ +
+ )}
@@ -307,10 +330,16 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp ); } +interface EmptySlotHoverCardProps { + children: ReactNode; + className?: string; + configureSlot?: ConfigureSlotConfig; +} + /** - * Wrapper for empty slots - just shows "Empty" on hover + * Wrapper for empty slots - shows "Empty" on hover with optional configure button */ -export function EmptySlotHoverCard({ children, className = '' }: { children: ReactNode; className?: string }) { +export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) { const [isVisible, setIsVisible] = useState(false); const timeoutRef = useRef | null>(null); @@ -344,10 +373,28 @@ export function EmptySlotHoverCard({ children, className = '' }: { children: Rea animate-in fade-in-0 zoom-in-95 duration-150 ">
- Empty slot +
+ Empty slot +
+ {/* Configure slot button */} + {configureSlot?.enabled && ( +
+ +
+ )}

- Plate {plate.index} + {plate.name || `Plate ${plate.index}`}

- {plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`} + {plate.objects?.length > 0 + ? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '') + : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`} {plate.print_time_seconds ? ` • ${formatTime(plate.print_time_seconds)}` : ''}

diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 45f3395a8..5cc8d4a78 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -80,6 +80,17 @@ function formatDuration(seconds: number): string { return `${minutes}m`; } +/** + * Check if an archive filename represents a sliced/printable file. + * Matches: .gcode, .gcode.3mf, .gcode.anything + */ +function isSlicedFile(filename: string | null | undefined): boolean { + if (!filename) return false; + const lower = filename.toLowerCase(); + // Match .gcode at end OR .gcode. followed by anything (like .gcode.3mf) + return lower.endsWith('.gcode') || lower.includes('.gcode.'); +} + // formatDate imported from '../utils/date' - handles UTC conversion function ArchiveCard({ @@ -246,7 +257,7 @@ function ArchiveCard({ setContextMenu({ x: e.clientX, y: e.clientY }); }; - const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.'); + const isGcodeFile = isSlicedFile(archive.filename); const contextMenuItems: ContextMenuItem[] = [ // For gcode files: show Print option @@ -632,17 +643,17 @@ function ArchiveCard({ {/* File type badge */} - {archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'} + {isSlicedFile(archive.filename) ? 'GCODE' : 'SOURCE'} {archive.project_name && ( - {archive.filename?.toLowerCase().includes('.gcode.') ? ( + {isSlicedFile(archive.filename) ? ( // Sliced file - can print directly <>