diff --git a/backend/app/api/routes/printer_control.py b/backend/app/api/routes/printer_control.py index c340653bc..e74f1f0a9 100644 --- a/backend/app/api/routes/printer_control.py +++ b/backend/app/api/routes/printer_control.py @@ -133,6 +133,12 @@ class MoveRequest(ConfirmableRequest): class AMSLoadRequest(BaseModel): tray_id: int = Field(..., ge=0, le=254, description="Tray ID (0-15 for AMS, 254 for external)") + extruder_id: int | None = Field(default=None, ge=0, le=1, description="Extruder ID for dual-nozzle printers (0=right, 1=left)") + + +class AMSRefreshTrayRequest(BaseModel): + ams_id: int = Field(..., ge=0, le=128, description="AMS unit ID (0-3, or 128 for H2D external)") + tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)") class GcodeRequest(ConfirmableRequest): @@ -587,10 +593,11 @@ async def ams_load_filament( if client.state.state == "RUNNING": raise HTTPException(status_code=400, detail="Cannot change filament during print") - success = client.ams_load_filament(request.tray_id) + success = client.ams_load_filament(request.tray_id, request.extruder_id) + extruder_info = f" to extruder {request.extruder_id}" if request.extruder_id is not None else "" return ControlResponse( success=success, - message=f"Loading filament from tray {request.tray_id}" if success else "Failed to load filament" + message=f"Loading filament from tray {request.tray_id}{extruder_info}" if success else "Failed to load filament" ) @@ -614,6 +621,23 @@ async def ams_unload_filament( ) +@router.post("/{printer_id}/control/ams/refresh-tray", response_model=ControlResponse) +async def ams_refresh_tray( + printer_id: int, + request: AMSRefreshTrayRequest, + db: AsyncSession = Depends(get_db), +): + """Trigger RFID re-read for a specific AMS tray.""" + await get_printer_or_404(printer_id, db) + client = get_mqtt_client_or_503(printer_id) + + success = client.ams_refresh_tray(request.ams_id, request.tray_id) + return ControlResponse( + success=success, + message=f"Refreshing AMS {request.ams_id} tray {request.tray_id}" if success else "Failed to refresh tray" + ) + + # ============================================================================= # Advanced: G-code Command # ============================================================================= diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index a62c86f00..22909acd0 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -13,6 +13,7 @@ from sqlalchemy import select from backend.app.core.database import get_db from backend.app.core.config import settings from backend.app.models.printer import Printer +from backend.app.models.slot_preset import SlotPresetMapping from backend.app.schemas.printer import ( PrinterCreate, PrinterUpdate, @@ -156,21 +157,54 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) ams_exists = False raw_data = state.raw_data or {} - if "ams" in raw_data: + if "ams" in raw_data and isinstance(raw_data["ams"], list): ams_exists = True for ams_data in raw_data["ams"]: + # Skip if ams_data is not a dict (defensive check) + if not isinstance(ams_data, dict): + continue trays = [] for tray_data in ams_data.get("tray", []): + # Filter out empty/invalid tag values + tag_uid = tray_data.get("tag_uid", "") + if tag_uid in ("", "0000000000000000"): + tag_uid = None + tray_uuid = tray_data.get("tray_uuid", "") + if tray_uuid in ("", "00000000000000000000000000000000"): + tray_uuid = None trays.append(AMSTray( id=tray_data.get("id", 0), tray_color=tray_data.get("tray_color"), tray_type=tray_data.get("tray_type"), + tray_sub_brands=tray_data.get("tray_sub_brands"), + tray_id_name=tray_data.get("tray_id_name"), + tray_info_idx=tray_data.get("tray_info_idx"), remain=tray_data.get("remain", 0), k=tray_data.get("k"), + tag_uid=tag_uid, + tray_uuid=tray_uuid, + nozzle_temp_min=tray_data.get("nozzle_temp_min"), + nozzle_temp_max=tray_data.get("nozzle_temp_max"), )) + # Prefer humidity_raw (percentage) over humidity (index 1-5) + # humidity_raw is the actual percentage value from the sensor + humidity_raw = ams_data.get("humidity_raw") + humidity_idx = ams_data.get("humidity") + # Use humidity_raw if available, otherwise fall back to humidity index + humidity_value = None + if humidity_raw is not None: + try: + humidity_value = int(humidity_raw) + except (ValueError, TypeError): + pass + if humidity_value is None and humidity_idx is not None: + try: + humidity_value = int(humidity_idx) + except (ValueError, TypeError): + pass ams_units.append(AMSUnit( id=ams_data.get("id", 0), - humidity=ams_data.get("humidity"), + humidity=humidity_value, temp=ams_data.get("temp"), tray=trays, )) @@ -178,12 +212,24 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) # Virtual tray (external spool holder) - comes from vt_tray in raw_data if "vt_tray" in raw_data: vt_data = raw_data["vt_tray"] + # Filter out empty/invalid tag values for vt_tray + vt_tag_uid = vt_data.get("tag_uid", "") + if vt_tag_uid in ("", "0000000000000000"): + vt_tag_uid = None + vt_tray_uuid = vt_data.get("tray_uuid", "") + if vt_tray_uuid in ("", "00000000000000000000000000000000"): + vt_tray_uuid = None vt_tray = AMSTray( id=254, # Virtual tray ID tray_color=vt_data.get("tray_color"), tray_type=vt_data.get("tray_type"), + tray_sub_brands=vt_data.get("tray_sub_brands"), remain=vt_data.get("remain", 0), k=vt_data.get("k"), + tag_uid=vt_tag_uid, + tray_uuid=vt_tray_uuid, + nozzle_temp_min=vt_data.get("nozzle_temp_min"), + nozzle_temp_max=vt_data.get("nozzle_temp_max"), ) # Convert nozzle info to response format @@ -214,6 +260,18 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) filament_tangle_detect=state.print_options.filament_tangle_detect, ) + # Get AMS mapping from raw_data (which AMS is connected to which nozzle) + ams_mapping = raw_data.get("ams_mapping", []) + # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left + ams_extruder_map = raw_data.get("ams_extruder_map", {}) + logger.debug(f"API returning ams_mapping: {ams_mapping}, ams_extruder_map: {ams_extruder_map}") + + # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id + # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID + # No conversion needed - just use the raw value directly + tray_now = state.tray_now + logger.debug(f"Using tray_now directly as global ID: {tray_now}") + return PrinterStatus( id=printer_id, name=printer.name, @@ -245,6 +303,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) speed_level=state.speed_level, chamber_light=state.chamber_light, active_extruder=state.active_extruder, + ams_mapping=ams_mapping, + ams_extruder_map=ams_extruder_map, + tray_now=tray_now, ) @@ -701,3 +762,133 @@ async def start_calibration( "nozzle_offset": nozzle_offset, "high_temp_heatbed": high_temp_heatbed, } + + +# ============================================================================ +# Slot Preset Mapping Endpoints +# ============================================================================ + + +@router.get("/{printer_id}/slot-presets") +async def get_slot_presets( + printer_id: int, + db: AsyncSession = Depends(get_db), +): + """Get all saved slot-to-preset mappings for a printer.""" + result = await db.execute( + select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id) + ) + mappings = result.scalars().all() + + return { + mapping.ams_id * 4 + mapping.tray_id: { + "ams_id": mapping.ams_id, + "tray_id": mapping.tray_id, + "preset_id": mapping.preset_id, + "preset_name": mapping.preset_name, + } + for mapping in mappings + } + + +@router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}") +async def get_slot_preset( + printer_id: int, + ams_id: int, + tray_id: int, + db: AsyncSession = Depends(get_db), +): + """Get the saved preset for a specific slot.""" + 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 not mapping: + return None + + return { + "ams_id": mapping.ams_id, + "tray_id": mapping.tray_id, + "preset_id": mapping.preset_id, + "preset_name": mapping.preset_name, + } + + +@router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}") +async def save_slot_preset( + printer_id: int, + ams_id: int, + tray_id: int, + preset_id: str, + preset_name: str, + db: AsyncSession = Depends(get_db), +): + """Save a preset mapping for a specific slot.""" + # Check printer exists + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + if not result.scalar_one_or_none(): + raise HTTPException(404, "Printer not found") + + # Check for existing 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: + # Update existing + mapping.preset_id = preset_id + mapping.preset_name = preset_name + else: + # Create new + mapping = SlotPresetMapping( + printer_id=printer_id, + ams_id=ams_id, + tray_id=tray_id, + preset_id=preset_id, + preset_name=preset_name, + ) + db.add(mapping) + + await db.commit() + await db.refresh(mapping) + + return { + "ams_id": mapping.ams_id, + "tray_id": mapping.tray_id, + "preset_id": mapping.preset_id, + "preset_name": mapping.preset_name, + } + + +@router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}") +async def delete_slot_preset( + printer_id: int, + ams_id: int, + tray_id: int, + db: AsyncSession = Depends(get_db), +): + """Delete a saved preset mapping for a slot.""" + 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() + + return {"success": True} diff --git a/backend/app/models/slot_preset.py b/backend/app/models/slot_preset.py new file mode 100644 index 000000000..7218aa90b --- /dev/null +++ b/backend/app/models/slot_preset.py @@ -0,0 +1,37 @@ +"""Model for storing AMS slot to filament preset mappings. + +This stores the user's preferred filament preset for each AMS slot, +similar to how Bambu Studio remembers preset selections. +""" + +from datetime import datetime +from sqlalchemy import String, Integer, DateTime, ForeignKey, func, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class SlotPresetMapping(Base): + """Maps an AMS slot to a cloud filament preset.""" + + __tablename__ = "slot_preset_mappings" + __table_args__ = ( + UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE")) + ams_id: Mapped[int] = mapped_column(Integer) # AMS unit ID (0, 1, 2, 3) + tray_id: Mapped[int] = mapped_column(Integer) # Tray ID within AMS (0-3) + preset_id: Mapped[str] = mapped_column(String(100)) # Cloud preset setting_id + preset_name: Mapped[str] = mapped_column(String(200)) # Preset name for display + 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 + printer: Mapped["Printer"] = relationship() + + +from backend.app.models.printer import Printer # noqa: E402 diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 115e98903..8dc0625cd 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -121,3 +121,9 @@ class PrinterStatus(BaseModel): chamber_light: bool = False # Active extruder for dual nozzle (0=right, 1=left) active_extruder: int = 0 + # AMS mapping for dual nozzle: which AMS is connected to which nozzle + ams_mapping: list[int] = [] + # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left + ams_extruder_map: dict[str, int] = {} + # Currently loaded tray (global ID): 254 = external spool, 255 = no filament + tray_now: int = 255 diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index c775c5272..4b2f7bf38 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -112,6 +112,10 @@ class PrinterState: chamber_light: bool = False # Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow active_extruder: int = 0 + # Currently loaded tray (global ID): 254 = external spool, 255 = no filament + tray_now: int = 255 + # Pending load target - used to track what tray we're loading for H2D disambiguation + pending_tray_target: int | None = None # Stage name mapping from BambuStudio DeviceManager.cpp @@ -236,6 +240,11 @@ class BambuMQTTClient: self._xcam_hold_start: dict[str, float] = {} self._xcam_hold_time: float = 3.0 # Ignore incoming data for 3 seconds after command + # Track last requested tray ID for H2D dual-nozzle printers + # H2D only reports slot number (0-3) in tray_now, not global tray ID + # We use our tracked value to resolve the correct global ID + self._last_load_tray_id: int | None = None + @property def topic_subscribe(self) -> str: return f"device/{self.serial_number}/report" @@ -346,7 +355,12 @@ class BambuMQTTClient: # Handle vt_tray (virtual tray / external spool) data if "vt_tray" in print_data: - self.state.raw_data["vt_tray"] = print_data["vt_tray"] + vt_tray = print_data["vt_tray"] + self.state.raw_data["vt_tray"] = vt_tray + # Log vt_tray to investigate per-extruder data for H2D + if not hasattr(self, '_vt_tray_logged') or not self._vt_tray_logged: + logger.info(f"[{self.serial_number}] vt_tray data: {vt_tray}") + self._vt_tray_logged = True # Check for K-profile response (extrusion_cali) if "command" in print_data: @@ -572,6 +586,63 @@ class BambuMQTTClient: # Handle nested ams structure: {"ams": {"ams": [...]}} or {"ams": [...]} if isinstance(ams_data, dict) and "ams" in ams_data: ams_list = ams_data["ams"] + # Log all AMS dict fields to debug tray_now for H2D dual-nozzle + non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"} + if non_list_fields: + logger.info(f"[{self.serial_number}] AMS dict fields: {non_list_fields}") + # Parse tray_now from AMS dict - this is the currently loaded tray global ID + if "tray_now" in ams_data: + raw_tray_now = ams_data["tray_now"] + # Convert string to int if needed + if isinstance(raw_tray_now, str): + try: + parsed_tray_now = int(raw_tray_now) + except ValueError: + parsed_tray_now = 255 + else: + parsed_tray_now = raw_tray_now if raw_tray_now is not None else 255 + + # H2D dual-nozzle printers report only slot number (0-3), not global tray ID + # Use pending_tray_target from our load command tracking for disambiguation + if parsed_tray_now >= 0 and parsed_tray_now <= 3: + # Check if we have a pending target that matches this slot + pending_target = self.state.pending_tray_target + if pending_target is not None: + pending_slot = pending_target % 4 + if pending_slot == parsed_tray_now: + # Slot matches our pending target - use the full global ID + logger.info( + f"[{self.serial_number}] H2D tray_now disambiguation: " + f"slot {parsed_tray_now} matches pending_tray_target {pending_target} -> using global ID {pending_target}" + ) + self.state.tray_now = pending_target + # Clear pending target now that load is confirmed + self.state.pending_tray_target = None + else: + # Slot doesn't match our pending target - something changed, use slot as-is + logger.warning( + f"[{self.serial_number}] H2D tray_now: slot {parsed_tray_now} doesn't match " + f"pending_tray_target {pending_target} (slot {pending_slot}) - using slot as global ID" + ) + self.state.tray_now = parsed_tray_now + # Clear pending target since it's stale + self.state.pending_tray_target = None + else: + # No pending target, use slot number as-is + # This happens when filament was loaded before app started or via printer touchscreen + logger.debug( + f"[{self.serial_number}] H2D tray_now: no pending_tray_target, " + f"using slot {parsed_tray_now} as global ID (may be incorrect for H2D)" + ) + self.state.tray_now = parsed_tray_now + else: + # tray_now > 3 means it's already a global ID, or 255 means unloaded + if parsed_tray_now == 255: + # Filament unloaded - clear pending target + self.state.pending_tray_target = None + self.state.tray_now = parsed_tray_now + + logger.debug(f"[{self.serial_number}] tray_now updated: {self.state.tray_now}") elif isinstance(ams_data, list): ams_list = ams_data else: @@ -582,6 +653,35 @@ class BambuMQTTClient: self.state.raw_data["ams"] = ams_list logger.debug(f"[{self.serial_number}] Stored AMS data with {len(ams_list)} units") + # Extract ams_extruder_map from each AMS unit's info field + # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left) + # Log AMS unit fields once to discover available fields + if not hasattr(self, '_ams_fields_logged') and ams_list: + first_unit = ams_list[0] + logger.info(f"[{self.serial_number}] AMS unit fields: {sorted(first_unit.keys())}") + for ams_unit in ams_list: + ams_id = ams_unit.get("id") + unit_info = {k: v for k, v in ams_unit.items() if k != "tray"} + logger.info(f"[{self.serial_number}] AMS {ams_id} data: {unit_info}") + self._ams_fields_logged = True + + ams_extruder_map = {} + for ams_unit in ams_list: + ams_id = ams_unit.get("id") + info = ams_unit.get("info") + if ams_id is not None and info is not None: + try: + info_val = int(info) if isinstance(info, str) else info + # Extract bit 8 for extruder assignment (0=right, 1=left) + extruder_id = (info_val >> 8) & 0x1 + ams_extruder_map[str(ams_id)] = extruder_id + logger.debug(f"[{self.serial_number}] AMS {ams_id} info={info_val} -> extruder {extruder_id}") + except (ValueError, TypeError): + pass + if ams_extruder_map: + self.state.raw_data["ams_extruder_map"] = ams_extruder_map + logger.debug(f"[{self.serial_number}] ams_extruder_map: {ams_extruder_map}") + # Create a hash of relevant AMS data to detect changes ams_hash_data = [] for ams_unit in ams_list: @@ -649,6 +749,11 @@ class BambuMQTTClient: logger.info(f"[{self.serial_number}] ALL print data keys ({len(all_keys)}): {all_keys}") self._temp_fields_logged = True + # Log vir_slot data (once) - this may contain per-extruder slot mapping for H2D + if "vir_slot" in data and not hasattr(self, '_vir_slot_logged'): + logger.info(f"[{self.serial_number}] vir_slot data: {data['vir_slot']}") + self._vir_slot_logged = True + # Log nozzle hardware info fields (once) nozzle_fields = {k: v for k, v in data.items() if 'nozzle' in k.lower() or 'hw' in k.lower() or 'extruder' in k.lower() or 'upgrade' in k.lower()} if nozzle_fields and not hasattr(self, '_nozzle_fields_logged'): @@ -1077,14 +1182,17 @@ class BambuMQTTClient: if "diameter" in nozzle: self.state.nozzles[idx].nozzle_diameter = str(nozzle["diameter"]) - # Preserve AMS and vt_tray data when updating raw_data + # Preserve AMS, vt_tray, and ams_extruder_map data when updating raw_data ams_data = self.state.raw_data.get("ams") vt_tray_data = self.state.raw_data.get("vt_tray") + ams_extruder_map_data = self.state.raw_data.get("ams_extruder_map") self.state.raw_data = data if ams_data is not None: self.state.raw_data["ams"] = ams_data if vt_tray_data is not None: self.state.raw_data["vt_tray"] = vt_tray_data + if ams_extruder_map_data is not None: + self.state.raw_data["ams_extruder_map"] = ams_extruder_map_data # Log state transitions for debugging if "gcode_state" in data: @@ -2178,11 +2286,13 @@ class BambuMQTTClient: """ return self.send_gcode("M17") - def ams_load_filament(self, tray_id: int) -> bool: + def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool: """Load filament from a specific AMS tray. Args: - tray_id: Tray ID (0-15 for AMS slots, or 254 for external spool) + tray_id: Global tray ID (0-15 for AMS slots, or 254 for external spool) + extruder_id: Extruder ID for dual-nozzle printers (0=right, 1=left). + If None, defaults to 0. Returns: True if command was sent, False otherwise @@ -2191,15 +2301,40 @@ class BambuMQTTClient: logger.warning(f"[{self.serial_number}] Cannot load filament: not connected") return False + # Default to extruder 0 if not specified + curr_nozzle = extruder_id if extruder_id is not None else 0 + + # Convert global tray_id to ams_id and slot_id + # External spool is special case (254) + if tray_id == 254: + ams_id = 255 # External spool + slot_id = 254 + else: + ams_id = tray_id // 4 # AMS unit (0, 1, 2, 3...) + slot_id = tray_id % 4 # Slot within AMS (0, 1, 2, 3) + + # Build command with ams_id and slot_id format (per HA-Bambulab integration) command = { "print": { "command": "ams_change_filament", - "target": tray_id, + "target": tray_id, # Keep target for compatibility + "ams_id": ams_id, + "slot_id": slot_id, + "curr_temp": 220, + "tar_temp": 220, "sequence_id": "0" } } + self._client.publish(self.topic_publish, json.dumps(command)) - logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id}") + logger.info(f"[{self.serial_number}] Loading filament from AMS {ams_id} slot {slot_id} (global tray {tray_id})") + + # Track this load request for H2D dual-nozzle disambiguation + # H2D reports only slot number (0-3) in tray_now, so we use our tracked value + self._last_load_tray_id = tray_id + self.state.pending_tray_target = tray_id + logger.info(f"[{self.serial_number}] Set pending_tray_target={tray_id} for H2D disambiguation") + return True def ams_unload_filament(self) -> bool: @@ -2221,6 +2356,12 @@ class BambuMQTTClient: } self._client.publish(self.topic_publish, json.dumps(command)) logger.info(f"[{self.serial_number}] Unloading filament") + + # Clear tracked load request since we're unloading + self._last_load_tray_id = None + self.state.pending_tray_target = None + logger.info(f"[{self.serial_number}] Cleared pending_tray_target (unload)") + return True def ams_control(self, action: str) -> bool: @@ -2251,6 +2392,34 @@ class BambuMQTTClient: logger.info(f"[{self.serial_number}] AMS control: {action}") return True + def ams_refresh_tray(self, ams_id: int, tray_id: int) -> bool: + """Trigger RFID re-read for a specific AMS tray. + + Args: + ams_id: AMS unit ID (0-3, or 128 for H2D external tray) + 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 refresh AMS tray: not connected") + return False + + # Use ams_get_rfid command to trigger RFID re-read + # This command is used by Bambu Studio to re-read the RFID tag + command = { + "print": { + "command": "ams_get_rfid", + "ams_id": ams_id, + "slot_id": tray_id, + "sequence_id": "0" + } + } + self._client.publish(self.topic_publish, json.dumps(command)) + logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}") + return True + def set_timelapse(self, enable: bool) -> bool: """Enable or disable timelapse recording. diff --git a/frontend/public/icons/extruder-change-filament.png b/frontend/public/icons/extruder-change-filament.png index c709c2b51..992f02454 100644 Binary files a/frontend/public/icons/extruder-change-filament.png and b/frontend/public/icons/extruder-change-filament.png differ diff --git a/frontend/src/components/control/AMSMaterialsModal.tsx b/frontend/src/components/control/AMSMaterialsModal.tsx index 6cf1596df..698e02a86 100644 --- a/frontend/src/components/control/AMSMaterialsModal.tsx +++ b/frontend/src/components/control/AMSMaterialsModal.tsx @@ -57,9 +57,12 @@ export interface MaterialSettings { function hexToRgb(hex: string | null): string { if (!hex) return 'rgb(128, 128, 128)'; const cleanHex = hex.replace('#', '').substring(0, 6); - const r = parseInt(cleanHex.substring(0, 2), 16) || 128; - const g = parseInt(cleanHex.substring(2, 4), 16) || 128; - const b = parseInt(cleanHex.substring(4, 6), 16) || 128; + const rParsed = parseInt(cleanHex.substring(0, 2), 16); + const gParsed = parseInt(cleanHex.substring(2, 4), 16); + const bParsed = parseInt(cleanHex.substring(4, 6), 16); + const r = isNaN(rParsed) ? 128 : rParsed; + const g = isNaN(gParsed) ? 128 : gParsed; + const b = isNaN(bParsed) ? 128 : bParsed; return `rgb(${r}, ${g}, ${b})`; } diff --git a/frontend/src/components/control/AMSPanel.tsx b/frontend/src/components/control/AMSPanel.tsx index 3f847e17f..ddec7c1a7 100644 --- a/frontend/src/components/control/AMSPanel.tsx +++ b/frontend/src/components/control/AMSPanel.tsx @@ -13,9 +13,12 @@ function hexToRgb(hex: string | null): string { if (!hex) return 'rgb(128, 128, 128)'; // Handle RRGGBBAA format const cleanHex = hex.replace('#', '').substring(0, 6); - const r = parseInt(cleanHex.substring(0, 2), 16) || 128; - const g = parseInt(cleanHex.substring(2, 4), 16) || 128; - const b = parseInt(cleanHex.substring(4, 6), 16) || 128; + const rParsed = parseInt(cleanHex.substring(0, 2), 16); + const gParsed = parseInt(cleanHex.substring(2, 4), 16); + const bParsed = parseInt(cleanHex.substring(4, 6), 16); + const r = isNaN(rParsed) ? 128 : rParsed; + const g = isNaN(gParsed) ? 128 : gParsed; + const b = isNaN(bParsed) ? 128 : bParsed; return `rgb(${r}, ${g}, ${b})`; } diff --git a/frontend/src/components/control/AMSSectionDual.tsx b/frontend/src/components/control/AMSSectionDual.tsx index 7ce97b79b..8f664b81b 100644 --- a/frontend/src/components/control/AMSSectionDual.tsx +++ b/frontend/src/components/control/AMSSectionDual.tsx @@ -22,20 +22,29 @@ interface AMSSectionDualProps { function hexToRgb(hex: string | null): string { if (!hex) return 'rgb(128, 128, 128)'; const cleanHex = hex.replace('#', '').substring(0, 6); - const r = parseInt(cleanHex.substring(0, 2), 16) || 128; - const g = parseInt(cleanHex.substring(2, 4), 16) || 128; - const b = parseInt(cleanHex.substring(4, 6), 16) || 128; + const rParsed = parseInt(cleanHex.substring(0, 2), 16); + const gParsed = parseInt(cleanHex.substring(2, 4), 16); + const bParsed = parseInt(cleanHex.substring(4, 6), 16); + const r = isNaN(rParsed) ? 128 : rParsed; + const g = isNaN(gParsed) ? 128 : gParsed; + const b = isNaN(bParsed) ? 128 : bParsed; return `rgb(${r}, ${g}, ${b})`; } function isLightColor(hex: string | null): boolean { if (!hex) return false; const cleanHex = hex.replace('#', '').substring(0, 6); - const r = parseInt(cleanHex.substring(0, 2), 16) || 0; - const g = parseInt(cleanHex.substring(2, 4), 16) || 0; - const b = parseInt(cleanHex.substring(4, 6), 16) || 0; - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance > 0.5; + // Ensure we have a valid 6-char hex + if (cleanHex.length < 6) return false; + const rParsed = parseInt(cleanHex.substring(0, 2), 16); + const gParsed = parseInt(cleanHex.substring(2, 4), 16); + const bParsed = parseInt(cleanHex.substring(4, 6), 16); + // If any parsing fails, treat as dark + if (isNaN(rParsed) || isNaN(gParsed) || isNaN(bParsed)) return false; + // Use relative luminance formula (WCAG) + const luminance = (0.299 * rParsed + 0.587 * gParsed + 0.114 * bParsed) / 255; + // Lower threshold (0.45) to ensure more colors get white text for better contrast + return luminance > 0.45; } // Single humidity icon that fills based on level @@ -73,27 +82,74 @@ interface StepInfo { function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) { const [isCollapsed, setIsCollapsed] = useState(false); + // Track the highest progress step reached to show proper progression + // 0 = initial, 1 = push started, 2 = heating, 3 = purging + const [progressStep, setProgressStep] = useState(0); + const prevStageRef = useRef(currentStage); - // Determine step status based on current stage - // When stage is -1 (initial/waiting), show first step as in_progress + // Update progress based on stage transitions + useEffect(() => { + // For loading: track progression through stages + if (isLoading) { + if (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_CHANGING_FILAMENT) { + // Push/loading stage - at least step 1 + if (progressStep < 1) setProgressStep(1); + } else if (currentStage === STAGE_HEATING_NOZZLE) { + // Heating stage - at least step 2 + if (progressStep < 2) setProgressStep(2); + } else if (progressStep >= 2 && currentStage === STAGE_CHANGING_FILAMENT) { + // After heating, back to changing = purge stage + setProgressStep(3); + } + } else { + // For unloading: track heating -> unloading + if (currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { + if (progressStep < 1) setProgressStep(1); + } else if (currentStage === STAGE_FILAMENT_UNLOADING) { + if (progressStep < 2) setProgressStep(2); + } + } + + // Reset progress when stage returns to idle + if (currentStage === -1 && prevStageRef.current !== -1) { + // Operation completed, reset for next time + setProgressStep(0); + } + + prevStageRef.current = currentStage; + }, [currentStage, isLoading, progressStep]); + + // Initialize progress to step 1 when card first shows + useEffect(() => { + if (progressStep === 0) { + setProgressStep(1); + } + }, []); + + // Determine step status based on tracked progress const getLoadingSteps = (): StepInfo[] => { - // Loading sequence: Heat nozzle (7) -> Push filament (24) -> Purge (still 24 or complete) + // Loading sequence: Push filament -> Heat nozzle -> Purge let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending'; let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending'; let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending'; - if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { - // Initial state or heating - step 1 is active - step1Status = 'in_progress'; - } else if (currentStage === STAGE_FILAMENT_LOADING) { - // Loading filament - step 1 done, step 2 active + if (progressStep >= 3) { + // Purging - steps 1 & 2 done, step 3 active + step1Status = 'completed'; + step2Status = 'completed'; + step3Status = 'in_progress'; + } else if (progressStep >= 2) { + // Heating - step 1 done, step 2 active step1Status = 'completed'; step2Status = 'in_progress'; + } else if (progressStep >= 1) { + // Pushing - step 1 active + step1Status = 'in_progress'; } return [ - { label: 'Heat the nozzle', stepNumber: 1, status: step1Status }, - { label: 'Push new filament into extruder', stepNumber: 2, status: step2Status }, + { label: 'Push new filament into extruder', stepNumber: 1, status: step1Status }, + { label: 'Heat the nozzle', stepNumber: 2, status: step2Status }, { label: 'Purge old filament', stepNumber: 3, status: step3Status }, ]; }; @@ -102,13 +158,13 @@ function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChange let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending'; let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending'; - if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { - // Initial state or heating - step 1 is active - step1Status = 'in_progress'; - } else if (currentStage === STAGE_FILAMENT_UNLOADING) { - // Unloading filament - step 1 done, step 2 active + if (progressStep >= 2) { + // Unloading - step 1 done, step 2 active step1Status = 'completed'; step2Status = 'in_progress'; + } else if (progressStep >= 1) { + // Heating - step 1 active + step1Status = 'in_progress'; } return [ @@ -340,8 +396,8 @@ function AMSPanelContent({ : 'border-bambu-dark-tertiary hover:border-bambu-gray' } ${isEmpty ? 'opacity-50' : 'cursor-pointer'}`} > - {/* Fill level indicator - only for Bambu filaments with valid remain data */} - {!isEmpty && tray.tray_uuid && tray.remain >= 0 && ( + {/* Fill level indicator - show for any filament with valid remain data */} + {!isEmpty && tray.remain >= 0 && (
)} - {/* Full color background for non-Bambu filaments or no remain data */} - {!isEmpty && (!tray.tray_uuid || tray.remain < 0) && ( + {/* Full color background only when no valid remain data */} + {!isEmpty && tray.remain < 0 && (