Browse Source

Wiring up AMS module

Martin Ziegler 5 months ago
parent
commit
6a6ce3cc22

+ 26 - 2
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
 # =============================================================================

+ 193 - 2
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}

+ 37 - 0
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

+ 6 - 0
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

+ 175 - 6
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.
 

BIN
frontend/public/icons/extruder-change-filament.png


+ 6 - 3
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})`;
 }
 

+ 6 - 3
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})`;
 }
 

+ 108 - 63
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 && (
                     <div
                       className="absolute bottom-0 left-0 right-0 transition-all"
                       style={{
@@ -350,8 +406,8 @@ function AMSPanelContent({
                       }}
                     />
                   )}
-                  {/* 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 && (
                     <div
                       className="absolute inset-0"
                       style={{
@@ -371,9 +427,8 @@ function AMSPanelContent({
                   {/* Content overlay */}
                   <div className="relative w-full h-full flex flex-col items-center justify-end pb-[5px]">
                     <span
-                      className={`text-[11px] font-semibold mb-1 ${
-                        isLight ? 'text-gray-800' : 'text-white'
-                      } ${isLight ? '' : 'drop-shadow-sm'}`}
+                      className="text-[11px] font-semibold mb-1"
+                      style={{ color: isLight ? '#000000' : '#ffffff' }}
                     >
                       {isEmpty ? '--' : tray.tray_type}
                     </span>
@@ -633,34 +688,32 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
 
   // Distribute AMS units based on ams_extruder_map
   // Each AMS unit's info field tells us which extruder it's connected to:
-  // extruder 0 = right nozzle, extruder 1 = left nozzle
+  // In Bambu slicer convention: extruder 0 = left nozzle, extruder 1 = right nozzle
   const leftUnits = (() => {
     if (!isDualNozzle) return amsUnits;
     if (Object.keys(amsExtruderMap).length > 0) {
-      // Filter AMS units assigned to extruder 1 (left nozzle)
+      // Filter AMS units assigned to extruder 0 (left nozzle in slicer convention)
       // JSON keys are strings, so convert unit.id to string
-      return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
+      return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
     }
-    // Fallback: odd indices go to left (extruder 1)
-    return amsUnits.filter((_, i) => i % 2 === 1);
+    // Fallback: even indices go to left (extruder 0)
+    return amsUnits.filter((_, i) => i % 2 === 0);
   })();
 
   const rightUnits = (() => {
     if (!isDualNozzle) return [];
     if (Object.keys(amsExtruderMap).length > 0) {
-      // Filter AMS units assigned to extruder 0 (right nozzle)
+      // Filter AMS units assigned to extruder 1 (right nozzle in slicer convention)
       // JSON keys are strings, so convert unit.id to string
-      return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
+      return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
     }
-    // Fallback: even indices go to right (extruder 0)
-    return amsUnits.filter((_, i) => i % 2 === 0);
+    // Fallback: odd indices go to right (extruder 1)
+    return amsUnits.filter((_, i) => i % 2 === 1);
   })();
 
   const [leftAmsIndex, setLeftAmsIndex] = useState(0);
   const [rightAmsIndex, setRightAmsIndex] = useState(0);
   const [selectedTray, setSelectedTray] = useState<number | null>(null);
-  // Track if load has been triggered (to disable Load button until unload or slot change)
-  const [loadTriggered, setLoadTriggered] = useState(false);
 
   // Modal states
   const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null);
@@ -674,7 +727,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
   // Track if we've done initial sync from tray_now
   const initialSyncDone = useRef(false);
 
-  // Sync selectedTray and loadTriggered from status.tray_now on initial load
+  // Sync selectedTray from status.tray_now on initial load
   // tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool
   useEffect(() => {
     if (initialSyncDone.current) return;
@@ -683,10 +736,10 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
     if (trayNow !== undefined && trayNow !== null) {
       initialSyncDone.current = true;
       if (trayNow !== 255 && trayNow !== 254) {
-        // Valid AMS tray is loaded - select it and set loadTriggered
+        // Valid AMS tray is loaded - select it
+        // Note: We don't set loadTriggered here because the user may want to load a different slot
         console.log(`[AMSSectionDual] Initializing from tray_now: ${trayNow}`);
         setSelectedTray(trayNow);
-        setLoadTriggered(true);
       } else {
         // No filament loaded or external spool
         console.log(`[AMSSectionDual] tray_now=${trayNow} (no AMS filament loaded)`);
@@ -699,8 +752,6 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
       api.amsLoadFilament(printerId, trayId, extruderId),
     onSuccess: (data, { trayId, extruderId }) => {
       console.log(`[AMSSectionDual] Load filament success (tray ${trayId}, extruder ${extruderId}):`, data);
-      // Disable Load button after successful load
-      setLoadTriggered(true);
     },
     onError: (error, { trayId, extruderId }) => {
       console.error(`[AMSSectionDual] Load filament error (tray ${trayId}, extruder ${extruderId}):`, error);
@@ -711,20 +762,14 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
     mutationFn: () => api.amsUnloadFilament(printerId),
     onSuccess: (data) => {
       console.log(`[AMSSectionDual] Unload filament success:`, data);
-      // Re-enable Load button after unload
-      setLoadTriggered(false);
     },
     onError: (error) => {
       console.error(`[AMSSectionDual] Unload filament error:`, error);
     },
   });
 
-  // Handle tray selection - also re-enables Load button when changing slot
+  // Handle tray selection
   const handleTraySelect = (trayId: number | null) => {
-    if (trayId !== selectedTray) {
-      // Slot changed - re-enable Load button
-      setLoadTriggered(false);
-    }
     setSelectedTray(trayId);
   };
 
@@ -805,7 +850,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
   ].includes(currentStage);
 
   // Auto-close card when operation completes
-  // Track when we transition from an active filament change stage back to -1
+  // Track when we transition from an active filament change stage back to idle
   useEffect(() => {
     const wasInFilamentChange = [
       STAGE_HEATING_NOZZLE,
@@ -818,8 +863,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
       // MQTT is now reporting a stage, clear user-triggered state
       // Card will continue showing because isMqttFilamentChangeActive is true
       setUserFilamentChange(null);
-    } else if (wasInFilamentChange && currentStage === -1) {
-      // Transition from active stage to idle - operation completed
+    } else if (wasInFilamentChange && !isMqttFilamentChangeActive) {
+      // Transition from active stage to idle (any non-filament-change stage, not just -1)
       // Close the card by clearing user state
       setUserFilamentChange(null);
     }
@@ -867,8 +912,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
         const currentLeftUnit = leftUnits[leftAmsIndex];
         const currentRightUnit = rightUnits[rightAmsIndex];
 
-        if (extruderId === 1) {
-          // Left side (extruder 1)
+        if (extruderId === 0) {
+          // Left side (extruder 0) - leftUnits filters for amsExtruderMap === 0
           // Only show colored wiring if the currently displayed AMS unit is the one with loaded filament
           const isDisplayed = currentLeftUnit?.id === unit.id;
           return {
@@ -878,7 +923,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
             rightFilamentColor: null
           };
         } else {
-          // Right side (extruder 0)
+          // Right side (extruder 1) - rightUnits filters for amsExtruderMap === 1
           const isDisplayed = currentRightUnit?.id === unit.id;
           return {
             leftActiveSlot: null,
@@ -957,9 +1002,9 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
         <div className="flex items-center gap-2">
           <button
             onClick={handleUnload}
-            disabled={!isConnected || isPrinting || isLoading || !loadTriggered}
+            disabled={!isConnected || isPrinting || isLoading || trayNow === 255}
             className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
-              !isConnected || isPrinting || isLoading || !loadTriggered
+              !isConnected || isPrinting || isLoading || trayNow === 255
                 ? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
                 : 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
             }`}
@@ -972,9 +1017,9 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
           </button>
           <button
             onClick={handleLoad}
-            disabled={!isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered}
+            disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
             className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
-              !isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered
+              !isConnected || isPrinting || selectedTray === null || isLoading
                 ? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
                 : 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
             }`}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BEhbt1yZ.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BMICGIP2.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CKgM4K0H.css


BIN
static/icons/dual-extruder-left.png


BIN
static/icons/extruder-change-filament.png


+ 4 - 0
static/icons/humidity-empty.svg

@@ -0,0 +1,4 @@
+<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.8131 0.00537678C18.4463 -0.150913 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00537678ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z" fill="#D0D0D0"/>
+<path d="M8 46C12 48 24 48 28 46C26 50 22 52 18 52C14 52 10 50 8 46Z" fill="#1F8FEB"/>
+</svg>

+ 4 - 0
static/icons/humidity-full.svg

@@ -0,0 +1,4 @@
+<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z" fill="#1F8FEB"/>
+<path d="M17.7948 0.00537678C18.4273 -0.150913 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0133678 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00537678ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z" fill="#D0D0D0"/>
+</svg>

+ 4 - 0
static/icons/humidity-half.svg

@@ -0,0 +1,4 @@
+<svg width="35" height="53" viewBox="0 0 35 53" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.3165 0.00379674C17.932 -0.149588 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148377 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.00379674ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z" fill="#D0D0D0"/>
+<path d="M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z" fill="#1F8FEB"/>
+</svg>

+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-lJk_Q6s0.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CKgM4K0H.css">
+    <script type="module" crossorigin src="/assets/index-BMICGIP2.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BEhbt1yZ.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff