ソースを参照

Wiring up print options: first try. needs mofre reverse engineering for sync process

Martin Ziegler 5 ヶ月 前
コミット
071ca1cf4e

+ 1 - 0
.gitignore

@@ -42,3 +42,4 @@ archive/
 # Logs
 # Logs
 *.log
 *.log
 logs/
 logs/
+*.log*

+ 92 - 0
backend/app/api/routes/printers.py

@@ -22,6 +22,7 @@ from backend.app.schemas.printer import (
     AMSUnit,
     AMSUnit,
     AMSTray,
     AMSTray,
     NozzleInfoResponse,
     NozzleInfoResponse,
+    PrintOptionsResponse,
 )
 )
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.bambu_ftp import (
 from backend.app.services.bambu_ftp import (
@@ -193,6 +194,25 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         for n in (state.nozzles or [])
         for n in (state.nozzles or [])
     ]
     ]
 
 
+    # Convert print options to response format
+    print_options = PrintOptionsResponse(
+        spaghetti_detector=state.print_options.spaghetti_detector,
+        print_halt=state.print_options.print_halt,
+        halt_print_sensitivity=state.print_options.halt_print_sensitivity,
+        first_layer_inspector=state.print_options.first_layer_inspector,
+        printing_monitor=state.print_options.printing_monitor,
+        buildplate_marker_detector=state.print_options.buildplate_marker_detector,
+        allow_skip_parts=state.print_options.allow_skip_parts,
+        nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,
+        nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,
+        pileup_detector=state.print_options.pileup_detector,
+        pileup_sensitivity=state.print_options.pileup_sensitivity,
+        airprint_detector=state.print_options.airprint_detector,
+        airprint_sensitivity=state.print_options.airprint_sensitivity,
+        auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,
+        filament_tangle_detect=state.print_options.filament_tangle_detect,
+    )
+
     return PrinterStatus(
     return PrinterStatus(
         id=printer_id,
         id=printer_id,
         name=printer.name,
         name=printer.name,
@@ -212,9 +232,11 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         ams_exists=ams_exists,
         ams_exists=ams_exists,
         vt_tray=vt_tray,
         vt_tray=vt_tray,
         sdcard=state.sdcard,
         sdcard=state.sdcard,
+        store_to_sdcard=state.store_to_sdcard,
         timelapse=state.timelapse,
         timelapse=state.timelapse,
         ipcam=state.ipcam,
         ipcam=state.ipcam,
         nozzles=nozzles,
         nozzles=nozzles,
+        print_options=print_options,
     )
     )
 
 
 
 
@@ -542,3 +564,73 @@ async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
     printer_manager.clear_logs(printer_id)
     printer_manager.clear_logs(printer_id)
     return {"status": "cleared"}
     return {"status": "cleared"}
+
+
+# ============================================
+# Print Options (AI Detection) Endpoints
+# ============================================
+
+@router.post("/{printer_id}/print-options")
+async def set_print_option(
+    printer_id: int,
+    module_name: str,
+    enabled: bool,
+    print_halt: bool = True,
+    sensitivity: str = "medium",
+    db: AsyncSession = Depends(get_db),
+):
+    """Set an AI detection / print option on the printer.
+
+    Valid module_name values:
+    - spaghetti_detector: Spaghetti detection
+    - first_layer_inspector: First layer inspection
+    - printing_monitor: AI print quality monitoring
+    - buildplate_marker_detector: Build plate marker detection
+    - allow_skip_parts: Allow skipping failed parts
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client or not client.state.connected:
+        raise HTTPException(400, "Printer not connected")
+
+    # Validate module_name
+    valid_modules = [
+        "spaghetti_detector",
+        "first_layer_inspector",
+        "printing_monitor",
+        "buildplate_marker_detector",
+        "allow_skip_parts",
+        "pileup_detector",
+        "clump_detector",
+        "airprint_detector",
+        "auto_recovery_step_loss",
+    ]
+    if module_name not in valid_modules:
+        raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}")
+
+    # Validate sensitivity
+    valid_sensitivities = ["low", "medium", "high", "never_halt"]
+    if sensitivity not in valid_sensitivities:
+        raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}")
+
+    success = client.set_xcam_option(
+        module_name=module_name,
+        enabled=enabled,
+        print_halt=print_halt,
+        sensitivity=sensitivity,
+    )
+
+    if not success:
+        raise HTTPException(500, "Failed to send command to printer")
+
+    return {
+        "success": True,
+        "module_name": module_name,
+        "enabled": enabled,
+        "print_halt": print_halt,
+        "sensitivity": sensitivity,
+    }

+ 23 - 0
backend/app/schemas/printer.py

@@ -63,6 +63,27 @@ class NozzleInfoResponse(BaseModel):
     nozzle_diameter: str = ""  # e.g., "0.4"
     nozzle_diameter: str = ""  # e.g., "0.4"
 
 
 
 
+class PrintOptionsResponse(BaseModel):
+    """AI detection and print options from xcam data."""
+    # Core AI detectors
+    spaghetti_detector: bool = False
+    print_halt: bool = False
+    halt_print_sensitivity: str = "medium"  # Spaghetti sensitivity
+    first_layer_inspector: bool = False
+    printing_monitor: bool = False
+    buildplate_marker_detector: bool = False
+    allow_skip_parts: bool = False
+    # Additional AI detectors (decoded from cfg bitmask)
+    nozzle_clumping_detector: bool = True
+    nozzle_clumping_sensitivity: str = "medium"
+    pileup_detector: bool = True
+    pileup_sensitivity: str = "medium"
+    airprint_detector: bool = True
+    airprint_sensitivity: str = "medium"
+    auto_recovery_step_loss: bool = True
+    filament_tangle_detect: bool = False
+
+
 class PrinterStatus(BaseModel):
 class PrinterStatus(BaseModel):
     id: int
     id: int
     name: str
     name: str
@@ -82,6 +103,8 @@ class PrinterStatus(BaseModel):
     ams_exists: bool = False
     ams_exists: bool = False
     vt_tray: AMSTray | None = None  # Virtual tray / external spool
     vt_tray: AMSTray | None = None  # Virtual tray / external spool
     sdcard: bool = False  # SD card inserted
     sdcard: bool = False  # SD card inserted
+    store_to_sdcard: bool = False  # Store sent files on SD card
     timelapse: bool = False  # Timelapse recording active
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view enabled
     ipcam: bool = False  # Live view enabled
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
+    print_options: PrintOptionsResponse | None = None  # AI detection and print options

+ 385 - 7
backend/app/services/bambu_mqtt.py

@@ -54,6 +54,28 @@ class NozzleInfo:
     nozzle_diameter: str = ""  # e.g., "0.4"
     nozzle_diameter: str = ""  # e.g., "0.4"
 
 
 
 
+@dataclass
+class PrintOptions:
+    """AI detection and print options from xcam data."""
+    # Core AI detectors
+    spaghetti_detector: bool = False
+    print_halt: bool = False
+    halt_print_sensitivity: str = "medium"  # Spaghetti sensitivity
+    first_layer_inspector: bool = False
+    printing_monitor: bool = False  # AI print quality monitoring
+    buildplate_marker_detector: bool = False
+    allow_skip_parts: bool = False
+    # Additional AI detectors - decoded from cfg bitmask
+    nozzle_clumping_detector: bool = True
+    nozzle_clumping_sensitivity: str = "medium"
+    pileup_detector: bool = True
+    pileup_sensitivity: str = "medium"
+    airprint_detector: bool = True
+    airprint_sensitivity: str = "medium"
+    auto_recovery_step_loss: bool = True  # Uses print.print_option command
+    filament_tangle_detect: bool = False
+
+
 @dataclass
 @dataclass
 class PrinterState:
 class PrinterState:
     connected: bool = False
     connected: bool = False
@@ -71,10 +93,13 @@ class PrinterState:
     hms_errors: list = field(default_factory=list)  # List of HMSError
     hms_errors: list = field(default_factory=list)  # List of HMSError
     kprofiles: list = field(default_factory=list)  # List of KProfile
     kprofiles: list = field(default_factory=list)  # List of KProfile
     sdcard: bool = False  # SD card inserted
     sdcard: bool = False  # SD card inserted
+    store_to_sdcard: bool = False  # Store sent files on SD card (home_flag bit 11)
     timelapse: bool = False  # Timelapse recording active
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view / camera streaming enabled
     ipcam: bool = False  # Live view / camera streaming enabled
     # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
     # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
+    # AI detection and print options
+    print_options: PrintOptions = field(default_factory=PrintOptions)
 
 
 
 
 class BambuMQTTClient:
 class BambuMQTTClient:
@@ -117,6 +142,11 @@ class BambuMQTTClient:
         self._pending_kprofile_response: asyncio.Event | None = None
         self._pending_kprofile_response: asyncio.Event | None = None
         self._kprofile_response_data: list | None = None
         self._kprofile_response_data: list | None = None
 
 
+        # Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
+        # Key: module_name, Value: timestamp when command was sent
+        self._xcam_hold_start: dict[str, float] = {}
+        self._xcam_hold_time: float = 3.0  # Ignore incoming data for 3 seconds after command
+
     @property
     @property
     def topic_subscribe(self) -> str:
     def topic_subscribe(self) -> str:
         return f"device/{self.serial_number}/report"
         return f"device/{self.serial_number}/report"
@@ -184,15 +214,14 @@ class BambuMQTTClient:
             except Exception as e:
             except Exception as e:
                 logger.error(f"[{self.serial_number}] Error handling AMS data: {e}")
                 logger.error(f"[{self.serial_number}] Error handling AMS data: {e}")
 
 
-        # Handle xcam data (camera settings) at top level
+        # Handle xcam data (camera settings and AI detection) at top level
         if "xcam" in payload:
         if "xcam" in payload:
             xcam_data = payload["xcam"]
             xcam_data = payload["xcam"]
-            logger.debug(f"[{self.serial_number}] Received xcam data: {xcam_data}")
-            if isinstance(xcam_data, dict):
-                if "ipcam_record" in xcam_data:
-                    self.state.ipcam = xcam_data.get("ipcam_record") == "enable"
-                if "timelapse" in xcam_data:
-                    self.state.timelapse = xcam_data.get("timelapse") == "enable"
+            logger.info(f"[{self.serial_number}] Received xcam data at top level: {xcam_data}")
+            self._parse_xcam_data(xcam_data)
+            # Fire state change callback for top-level xcam (not nested in "print")
+            if "print" not in payload and self.on_state_change:
+                self.on_state_change(self.state)
 
 
         # Handle system responses (accessories info, etc.)
         # Handle system responses (accessories info, etc.)
         if "system" in payload:
         if "system" in payload:
@@ -202,6 +231,12 @@ class BambuMQTTClient:
 
 
         if "print" in payload:
         if "print" in payload:
             print_data = payload["print"]
             print_data = payload["print"]
+
+            # Check if xcam is nested inside print data
+            if "xcam" in print_data:
+                logger.info(f"[{self.serial_number}] Found xcam inside print data: {print_data['xcam']}")
+                self._parse_xcam_data(print_data["xcam"])
+
             # Log when we see gcode_state changes
             # Log when we see gcode_state changes
             if "gcode_state" in print_data:
             if "gcode_state" in print_data:
                 logger.info(
                 logger.info(
@@ -244,6 +279,195 @@ class BambuMQTTClient:
             # actual nozzle is 'HH01' hardened steel high-flow)
             # actual nozzle is 'HH01' hardened steel high-flow)
             logger.info(f"[{self.serial_number}] Accessories response (not used for nozzle data): {data}")
             logger.info(f"[{self.serial_number}] Accessories response (not used for nozzle data): {data}")
 
 
+    def _parse_xcam_data(self, xcam_data):
+        """Parse xcam data for camera settings and AI detection options."""
+        if not isinstance(xcam_data, dict):
+            return
+
+        current_time = time.time()
+
+        # Helper to check if we should accept incoming value for a module
+        # OrcaSlicer pattern: simple hold timer, ignore ALL data for 3 seconds after command
+        def should_accept_value(module_name: str, incoming_value: bool) -> bool:
+            """Check if we should accept an incoming xcam value.
+
+            OrcaSlicer pattern: After sending a command, ignore incoming data
+            for 3 seconds. After that, accept whatever the printer sends.
+            """
+            if module_name not in self._xcam_hold_start:
+                return True  # No hold timer, accept incoming
+
+            hold_start = self._xcam_hold_start[module_name]
+            elapsed = current_time - hold_start
+
+            if elapsed > self._xcam_hold_time:
+                # Hold timer expired - accept incoming and clear hold
+                del self._xcam_hold_start[module_name]
+                logger.debug(
+                    f"[{self.serial_number}] Hold expired for {module_name}, accepting {incoming_value}"
+                )
+                return True
+
+            # Within hold period - ignore incoming data
+            logger.debug(
+                f"[{self.serial_number}] Ignoring {module_name}={incoming_value} "
+                f"(hold active, {elapsed:.1f}s < {self._xcam_hold_time}s)"
+            )
+            return False
+
+        # Log all xcam fields for debugging
+        logger.debug(f"[{self.serial_number}] Parsing xcam data - all fields: {list(xcam_data.keys())}")
+
+        # The cfg bitmask contains the ACTUAL detector states - the individual boolean
+        # fields (spaghetti_detector, etc.) are often stale/cached.
+        # CFG bitmask structure (each detector uses 3 bits: [sens_low, sens_high, enabled]):
+        # - Bits 5-7: spaghetti_detector (sens in 5-6, enabled in 7)
+        # - Bits 8-10: pileup_detector (sens in 8-9, enabled in 10)
+        # - Bits 11-13: clump_detector/nozzle_clumping (sens in 11-12, enabled in 13)
+        # - Bits 14-16: airprint_detector (sens in 14-15, enabled in 16)
+        # Sensitivity values: 0=low, 1=medium, 2=high
+        if "cfg" in xcam_data:
+            cfg = xcam_data["cfg"]
+            logger.debug(f"[{self.serial_number}] xcam cfg bitmask: {cfg} (binary: {bin(cfg)})")
+
+            def decode_detector(start_bit):
+                """Decode a detector from cfg: returns (enabled, sensitivity_str)"""
+                sens_bits = (cfg >> start_bit) & 0x3
+                enabled = bool((cfg >> (start_bit + 2)) & 1)
+                sensitivity = {0: "low", 1: "medium", 2: "high"}.get(sens_bits, "medium")
+                return enabled, sensitivity
+
+            # Spaghetti detector (bits 5-7)
+            cfg_spaghetti, cfg_sensitivity = decode_detector(5)
+            if should_accept_value("spaghetti_detector", cfg_spaghetti):
+                old_value = self.state.print_options.spaghetti_detector
+                if cfg_spaghetti != old_value:
+                    logger.info(f"[{self.serial_number}] spaghetti_detector changed (from cfg): {old_value} -> {cfg_spaghetti}")
+                self.state.print_options.spaghetti_detector = cfg_spaghetti
+
+            # Check hold timer for sensitivity before accepting
+            if "halt_print_sensitivity" not in self._xcam_hold_start:
+                if cfg_sensitivity != self.state.print_options.halt_print_sensitivity:
+                    logger.info(
+                        f"[{self.serial_number}] Sensitivity changed (from cfg): "
+                        f"{self.state.print_options.halt_print_sensitivity} -> {cfg_sensitivity}"
+                    )
+                    self.state.print_options.halt_print_sensitivity = cfg_sensitivity
+            else:
+                hold_start = self._xcam_hold_start["halt_print_sensitivity"]
+                elapsed = current_time - hold_start
+                if elapsed <= self._xcam_hold_time:
+                    logger.debug(
+                        f"[{self.serial_number}] Ignoring cfg sensitivity={cfg_sensitivity} "
+                        f"(hold active, {elapsed:.1f}s < {self._xcam_hold_time}s)"
+                    )
+                else:
+                    # Hold expired - accept from cfg
+                    if cfg_sensitivity != self.state.print_options.halt_print_sensitivity:
+                        logger.info(
+                            f"[{self.serial_number}] Sensitivity synced (from cfg after hold): "
+                            f"{self.state.print_options.halt_print_sensitivity} -> {cfg_sensitivity}"
+                        )
+                        self.state.print_options.halt_print_sensitivity = cfg_sensitivity
+                    del self._xcam_hold_start["halt_print_sensitivity"]
+
+            # Pileup detector (bits 8-10)
+            cfg_pileup, cfg_pileup_sens = decode_detector(8)
+            if should_accept_value("pileup_detector", cfg_pileup):
+                if cfg_pileup != self.state.print_options.pileup_detector:
+                    logger.info(f"[{self.serial_number}] pileup_detector changed (from cfg): {self.state.print_options.pileup_detector} -> {cfg_pileup}")
+                    self.state.print_options.pileup_detector = cfg_pileup
+            # Pileup sensitivity with hold timer
+            if "pileup_sensitivity" not in self._xcam_hold_start:
+                if cfg_pileup_sens != self.state.print_options.pileup_sensitivity:
+                    logger.info(f"[{self.serial_number}] pileup_sensitivity changed (from cfg): {self.state.print_options.pileup_sensitivity} -> {cfg_pileup_sens}")
+                    self.state.print_options.pileup_sensitivity = cfg_pileup_sens
+            else:
+                hold_start = self._xcam_hold_start["pileup_sensitivity"]
+                elapsed = current_time - hold_start
+                if elapsed > self._xcam_hold_time:
+                    if cfg_pileup_sens != self.state.print_options.pileup_sensitivity:
+                        logger.info(f"[{self.serial_number}] pileup_sensitivity synced (from cfg after hold): {self.state.print_options.pileup_sensitivity} -> {cfg_pileup_sens}")
+                        self.state.print_options.pileup_sensitivity = cfg_pileup_sens
+                    del self._xcam_hold_start["pileup_sensitivity"]
+
+            # Clump/nozzle clumping detector (bits 11-13)
+            cfg_clump, cfg_clump_sens = decode_detector(11)
+            if should_accept_value("clump_detector", cfg_clump):
+                if cfg_clump != self.state.print_options.nozzle_clumping_detector:
+                    logger.info(f"[{self.serial_number}] nozzle_clumping_detector changed (from cfg): {self.state.print_options.nozzle_clumping_detector} -> {cfg_clump}")
+                    self.state.print_options.nozzle_clumping_detector = cfg_clump
+            # Clump sensitivity with hold timer
+            if "nozzle_clumping_sensitivity" not in self._xcam_hold_start:
+                if cfg_clump_sens != self.state.print_options.nozzle_clumping_sensitivity:
+                    logger.info(f"[{self.serial_number}] nozzle_clumping_sensitivity changed (from cfg): {self.state.print_options.nozzle_clumping_sensitivity} -> {cfg_clump_sens}")
+                    self.state.print_options.nozzle_clumping_sensitivity = cfg_clump_sens
+            else:
+                hold_start = self._xcam_hold_start["nozzle_clumping_sensitivity"]
+                elapsed = current_time - hold_start
+                if elapsed > self._xcam_hold_time:
+                    if cfg_clump_sens != self.state.print_options.nozzle_clumping_sensitivity:
+                        logger.info(f"[{self.serial_number}] nozzle_clumping_sensitivity synced (from cfg after hold): {self.state.print_options.nozzle_clumping_sensitivity} -> {cfg_clump_sens}")
+                        self.state.print_options.nozzle_clumping_sensitivity = cfg_clump_sens
+                    del self._xcam_hold_start["nozzle_clumping_sensitivity"]
+
+            # Airprint detector (bits 14-16)
+            cfg_airprint, cfg_airprint_sens = decode_detector(14)
+            if should_accept_value("airprint_detector", cfg_airprint):
+                if cfg_airprint != self.state.print_options.airprint_detector:
+                    logger.info(f"[{self.serial_number}] airprint_detector changed (from cfg): {self.state.print_options.airprint_detector} -> {cfg_airprint}")
+                    self.state.print_options.airprint_detector = cfg_airprint
+            # Airprint sensitivity with hold timer
+            if "airprint_sensitivity" not in self._xcam_hold_start:
+                if cfg_airprint_sens != self.state.print_options.airprint_sensitivity:
+                    logger.info(f"[{self.serial_number}] airprint_sensitivity changed (from cfg): {self.state.print_options.airprint_sensitivity} -> {cfg_airprint_sens}")
+                    self.state.print_options.airprint_sensitivity = cfg_airprint_sens
+            else:
+                hold_start = self._xcam_hold_start["airprint_sensitivity"]
+                elapsed = current_time - hold_start
+                if elapsed > self._xcam_hold_time:
+                    if cfg_airprint_sens != self.state.print_options.airprint_sensitivity:
+                        logger.info(f"[{self.serial_number}] airprint_sensitivity synced (from cfg after hold): {self.state.print_options.airprint_sensitivity} -> {cfg_airprint_sens}")
+                        self.state.print_options.airprint_sensitivity = cfg_airprint_sens
+                    del self._xcam_hold_start["airprint_sensitivity"]
+
+        # Camera settings
+        if "ipcam_record" in xcam_data:
+            self.state.ipcam = xcam_data.get("ipcam_record") == "enable"
+        if "timelapse" in xcam_data:
+            self.state.timelapse = xcam_data.get("timelapse") == "enable"
+
+        # Skip spaghetti_detector boolean field - we read from cfg bitmask above
+        if "print_halt" in xcam_data:
+            self.state.print_options.print_halt = bool(xcam_data.get("print_halt"))
+        # Skip halt_print_sensitivity field - it's always stale ("medium")
+        # We read the actual sensitivity from cfg bits 5-6 above
+        if "first_layer_inspector" in xcam_data:
+            new_value = bool(xcam_data.get("first_layer_inspector"))
+            if should_accept_value("first_layer_inspector", new_value):
+                self.state.print_options.first_layer_inspector = new_value
+        if "printing_monitor" in xcam_data:
+            new_value = bool(xcam_data.get("printing_monitor"))
+            if should_accept_value("printing_monitor", new_value):
+                self.state.print_options.printing_monitor = new_value
+        if "buildplate_marker_detector" in xcam_data:
+            new_value = bool(xcam_data.get("buildplate_marker_detector"))
+            if should_accept_value("buildplate_marker_detector", new_value):
+                self.state.print_options.buildplate_marker_detector = new_value
+        if "allow_skip_parts" in xcam_data:
+            new_value = bool(xcam_data.get("allow_skip_parts"))
+            if should_accept_value("allow_skip_parts", new_value):
+                self.state.print_options.allow_skip_parts = new_value
+
+        # Additional AI detectors - these are decoded from cfg bitmask above, not from
+        # individual boolean fields (which are not sent by the printer)
+        # pileup_detector, nozzle_clumping_detector, airprint_detector - from cfg
+        # auto_recovery_step_loss and filament_tangle_detect - tracked locally only
+        if "auto_recovery_step_loss" in xcam_data:
+            self.state.print_options.auto_recovery_step_loss = bool(xcam_data.get("auto_recovery_step_loss"))
+        if "filament_tangle_detect" in xcam_data:
+            self.state.print_options.filament_tangle_detect = bool(xcam_data.get("filament_tangle_detect"))
+
     def _handle_ams_data(self, ams_data):
     def _handle_ams_data(self, ams_data):
         """Handle AMS data changes for Spoolman integration.
         """Handle AMS data changes for Spoolman integration.
 
 
@@ -380,6 +604,18 @@ class BambuMQTTClient:
         if "sdcard" in data:
         if "sdcard" in data:
             self.state.sdcard = data["sdcard"] is True
             self.state.sdcard = data["sdcard"] is True
 
 
+        # Parse home_flag for "Store Sent Files on External Storage" setting (bit 11)
+        if "home_flag" in data:
+            home_flag = data["home_flag"]
+            # Bit 11 controls "Store Sent Files on External Storage"
+            # Convert to unsigned 32-bit if negative
+            if home_flag < 0:
+                home_flag = home_flag & 0xFFFFFFFF
+            store_to_sdcard = bool((home_flag >> 11) & 1)
+            if store_to_sdcard != self.state.store_to_sdcard:
+                logger.info(f"[{self.serial_number}] store_to_sdcard changed: {self.state.store_to_sdcard} -> {store_to_sdcard}")
+            self.state.store_to_sdcard = store_to_sdcard
+
         # Parse timelapse status (recording active during print)
         # Parse timelapse status (recording active during print)
         if "timelapse" in data:
         if "timelapse" in data:
             logger.debug(f"[{self.serial_number}] timelapse field: {data['timelapse']}")
             logger.debug(f"[{self.serial_number}] timelapse field: {data['timelapse']}")
@@ -651,6 +887,148 @@ class BambuMQTTClient:
             return True
             return True
         return False
         return False
 
 
+    def set_xcam_option(
+        self,
+        module_name: str,
+        enabled: bool,
+        print_halt: bool = True,
+        sensitivity: str = "medium"
+    ) -> bool:
+        """Set an xcam (AI detection) option on the printer.
+
+        Args:
+            module_name: The xcam module to control (e.g., "spaghetti_detector",
+                        "first_layer_inspector", "printing_monitor", "buildplate_marker_detector")
+            enabled: Whether to enable or disable the feature
+            print_halt: Whether to halt print on detection (only applies to some detectors)
+            sensitivity: Sensitivity level ("low", "medium", "high", or "never_halt")
+
+        Returns:
+            True if command was sent, False if not connected
+        """
+        if not self._client or not self.state.connected:
+            return False
+
+        # auto_recovery_step_loss uses a different command format (print.print_option)
+        if module_name == "auto_recovery_step_loss":
+            return self._set_print_option("auto_recovery", enabled)
+
+        self._sequence_id += 1
+
+        # Build the xcam control command (exact OrcaSlicer format)
+        # Key findings from OrcaSlicer source:
+        # - Uses "xcam" wrapper (not "print")
+        # - print_halt is ALWAYS true (legacy protocol requirement)
+        # - Both "control" and "enable" are set to the same value
+        # - halt_print_sensitivity controls actual halt behavior
+        command = {
+            "xcam": {
+                "command": "xcam_control_set",
+                "sequence_id": str(self._sequence_id),
+                "module_name": module_name,
+                "control": enabled,
+                "enable": enabled,  # old protocol compatibility
+                "print_halt": True,  # ALWAYS true per OrcaSlicer
+            }
+        }
+
+        # Only add sensitivity if not "never_halt"
+        # OrcaSlicer uses halt_print_sensitivity for ALL detectors
+        # The module_name field determines which detector's sensitivity is being set
+        if sensitivity and sensitivity != "never_halt":
+            command["xcam"]["halt_print_sensitivity"] = sensitivity
+
+        command_json = json.dumps(command)
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        logger.info(f"[{self.serial_number}] Set xcam option: {module_name}={enabled}, sensitivity={sensitivity}")
+        logger.debug(f"[{self.serial_number}] MQTT command sent: {command_json}")
+
+        # OrcaSlicer pattern: Set hold timer to ignore incoming data for 3 seconds
+        # This prevents stale MQTT data from immediately overwriting our change
+        self._xcam_hold_start[module_name] = time.time()
+
+        # Update local state immediately for responsive UI
+        # NOTE: Spaghetti and Pileup sensitivities are linked in firmware
+        # When spaghetti_detector sensitivity is changed, pileup also changes
+        if module_name == "spaghetti_detector":
+            self.state.print_options.spaghetti_detector = enabled
+            self.state.print_options.print_halt = print_halt
+            if sensitivity and sensitivity != "never_halt":
+                # spaghetti_detector controls BOTH spaghetti and pileup sensitivities
+                self.state.print_options.halt_print_sensitivity = sensitivity
+                self.state.print_options.pileup_sensitivity = sensitivity
+                self._xcam_hold_start["halt_print_sensitivity"] = time.time()
+                self._xcam_hold_start["pileup_sensitivity"] = time.time()
+        elif module_name == "first_layer_inspector":
+            self.state.print_options.first_layer_inspector = enabled
+        elif module_name == "printing_monitor":
+            self.state.print_options.printing_monitor = enabled
+        elif module_name == "buildplate_marker_detector":
+            self.state.print_options.buildplate_marker_detector = enabled
+        elif module_name == "allow_skip_parts":
+            self.state.print_options.allow_skip_parts = enabled
+        elif module_name == "pileup_detector":
+            self.state.print_options.pileup_detector = enabled
+            # Pileup sensitivity is linked to spaghetti - both are set via spaghetti_detector
+        elif module_name == "clump_detector":
+            self.state.print_options.nozzle_clumping_detector = enabled
+            if sensitivity and sensitivity != "never_halt":
+                self.state.print_options.nozzle_clumping_sensitivity = sensitivity
+                self._xcam_hold_start["nozzle_clumping_sensitivity"] = time.time()
+        elif module_name == "airprint_detector":
+            self.state.print_options.airprint_detector = enabled
+            if sensitivity and sensitivity != "never_halt":
+                self.state.print_options.airprint_sensitivity = sensitivity
+                self._xcam_hold_start["airprint_sensitivity"] = time.time()
+        elif module_name == "auto_recovery_step_loss":
+            self.state.print_options.auto_recovery_step_loss = enabled
+
+        return True
+
+    def _set_print_option(self, option_name: str, enabled: bool) -> bool:
+        """Set a print option using the print.print_option command.
+
+        This is different from xcam_control_set and is used for options like:
+        - auto_recovery
+        - air_print_detect
+        - filament_tangle_detect
+        - nozzle_blob_detect
+        - sound_enable
+
+        Args:
+            option_name: The option to control (e.g., "auto_recovery")
+            enabled: Whether to enable or disable the option
+
+        Returns:
+            True if command was sent, False if not connected
+        """
+        if not self._client or not self.state.connected:
+            return False
+
+        self._sequence_id += 1
+
+        command = {
+            "print": {
+                "command": "print_option",
+                "sequence_id": str(self._sequence_id),
+                option_name: enabled,
+            }
+        }
+
+        command_json = json.dumps(command)
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        logger.info(f"[{self.serial_number}] Set print option: {option_name}={enabled}")
+
+        # Set hold timer
+        hold_key = f"print_option_{option_name}"
+        self._xcam_hold_start[hold_key] = time.time()
+
+        # Update local state immediately
+        if option_name == "auto_recovery":
+            self.state.print_options.auto_recovery_step_loss = enabled
+
+        return True
+
     def disconnect(self):
     def disconnect(self):
         """Disconnect from the printer."""
         """Disconnect from the printer."""
         if self._client:
         if self._client:

+ 2 - 1
frontend/public/icons/ams-settings.svg

@@ -1 +1,2 @@
-<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill="#000" fill-rule="evenodd"><path d="m2.25 6c0-.41421.33579-.75.75-.75h3.5c.41421 0 .75.33579.75.75s-.33579.75-.75.75h-3.5c-.41421 0-.75-.33579-.75-.75zm8.5 0c0-.41421.3358-.75.75-.75h9.5c.4142 0 .75.33579.75.75s-.3358.75-.75.75h-9.5c-.4142 0-.75-.33579-.75-.75z"/><path d="m9 4.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.79493 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.45507 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.45507-3.25-3.25z"/><path d="m21.75 12c0-.4142-.3358-.75-.75-.75h-2c-.4142 0-.75.3358-.75.75s.3358.75.75.75h2c.4142 0 .75-.3358.75-.75zm-7 0c0-.4142-.3358-.75-.75-.75h-5c-.41421 0-.75.3358-.75.75s.33579.75.75.75h5c.4142 0 .75-.3358.75-.75zm-9 0c0-.4142-.33579-.75-.75-.75h-2c-.41421 0-.75.3358-.75.75s.33579.75.75.75h2c.41421 0 .75-.3358.75-.75z"/><path d="m16.5 10.25c.9665 0 1.75.7835 1.75 1.75s-.7835 1.75-1.75 1.75-1.75-.7835-1.75-1.75.7835-1.75 1.75-1.75zm3.25 1.75c0-1.7949-1.4551-3.25-3.25-3.25s-3.25 1.4551-3.25 3.25 1.4551 3.25 3.25 3.25 3.25-1.4551 3.25-3.25z"/><path d="m2.25 18c0-.4142.33579-.75.75-.75h5c.41421 0 .75.3358.75.75s-.33579.75-.75.75h-5c-.41421 0-.75-.3358-.75-.75zm10 0c0-.4142.3358-.75.75-.75h8c.4142 0 .75.3358.75.75s-.3358.75-.75.75h-8c-.4142 0-.75-.3358-.75-.75z"/><path d="m10.5 16.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.7949 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.4551 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.4551-3.25-3.25z"/></g></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512"><g id="_01_align_center" data-name="01 align center"><path d="M15,24H9V20.487a9,9,0,0,1-2.849-1.646L3.107,20.6l-3-5.2L3.15,13.645a9.1,9.1,0,0,1,0-3.29L.107,8.6l3-5.2L6.151,5.159A9,9,0,0,1,9,3.513V0h6V3.513a9,9,0,0,1,2.849,1.646L20.893,3.4l3,5.2L20.85,10.355a9.1,9.1,0,0,1,0,3.29L23.893,15.4l-3,5.2-3.044-1.758A9,9,0,0,1,15,20.487Zm-4-2h2V18.973l.751-.194A6.984,6.984,0,0,0,16.994,16.9l.543-.553,2.623,1.515,1-1.732-2.62-1.513.206-.746a7.048,7.048,0,0,0,0-3.75l-.206-.746,2.62-1.513-1-1.732L17.537,7.649,16.994,7.1a6.984,6.984,0,0,0-3.243-1.875L13,5.027V2H11V5.027l-.751.194A6.984,6.984,0,0,0,7.006,7.1l-.543.553L3.84,6.134l-1,1.732L5.46,9.379l-.206.746a7.048,7.048,0,0,0,0,3.75l.206.746L2.84,16.134l1,1.732,2.623-1.515.543.553a6.984,6.984,0,0,0,3.243,1.875l.751.194Zm1-6a4,4,0,1,1,4-4A4,4,0,0,1,12,16Zm0-6a2,2,0,1,0,2,2A2,2,0,0,0,12,10Z"/></g></svg>

ファイルの差分が大きいため隠しています
+ 0 - 0
frontend/public/icons/chamber.svg


+ 2 - 1
frontend/public/icons/hotend.svg

@@ -1 +1,2 @@
-<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g id="Front_Heater" data-name="Front Heater"><path d="m33.14 29h2.86l-4-7-4 7h3l1.5 3.7a8.38 8.38 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m23.22 29h2.78l-4-7-4 7h3.06l1.5 3.7a8.35 8.35 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .24-1.39 8.4 8.4 0 0 1 .2-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m57.88 20.09a3 3 0 0 0 -1.49-1.84l-7.73-4.1a35.48 35.48 0 0 0 -33.32 0l-7.72 4.1a3 3 0 0 0 -1.22 4.2l9.72 17a3 3 0 0 0 2.6 1.55 3 3 0 0 0 1.39-.34 1 1 0 0 0 .42-1.35 1 1 0 0 0 -1.34-.42 1 1 0 0 1 -1.33-.39l-9.72-17a1 1 0 0 1 -.1-.81 1 1 0 0 1 .52-.69l7.72-4.1a33.46 33.46 0 0 1 31.44 0l7.73 4.1a1 1 0 0 1 .51.63 1 1 0 0 1 -.1.81l-9.72 17a1 1 0 0 1 -1.33.39l-.2-.11a10.82 10.82 0 0 0 -.36-6.72l-1.19-3h2.94l-4-7-4 7h2.9l1.5 3.7a8.38 8.38 0 0 1 -.93 8l-.66.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.64-.78a8.27 8.27 0 0 0 .84-1.3 3 3 0 0 0 4-1.17l9.72-17a3 3 0 0 0 .32-2.44z"/></g></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" width="512" height="512" viewBox="0 0 24 24"><path d="M20.5,24c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.113-.252,.409-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Zm-7,0c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.112-.252,.409-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Zm-7,0c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.112-.252,.41-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Z"/></svg>

+ 4 - 1
frontend/public/icons/lamp.svg

@@ -1 +1,4 @@
-<svg id="Ecommerce" enable-background="new 0 0 48 48" height="512" viewBox="0 0 48 48" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m25 7.03c0-.552-.447-1-1-1-5.514 0-10 4.486-10 10 0 .552.447 1 1 1s1-.448 1-1c0-4.411 3.589-8 8-8 .553 0 1-.448 1-1z"/><path d="m22.246 45.79h1.754 1.754c1.652 0 2.999-1.345 3-3v-1.099c1.032-.475 1.755-1.512 1.755-2.721v-3.72c.176-.382.281-.802.281-1.25 0-1.016.226-2 .671-2.927.441-.919 1.086-1.752 1.864-2.409 3.746-3.165 5.709-7.989 5.25-12.909-.7-7.375-6.813-13.189-14.265-13.525l-.31-.01-.354.011c-7.408.335-13.521 6.149-14.222 13.526-.458 4.917 1.505 9.742 5.251 12.906.778.658 1.423 1.491 1.864 2.41.445.927.671 1.911.671 2.927 0 .447.105.868.281 1.25v3.721c0 1.209.722 2.247 1.755 2.721v1.1c.001 1.653 1.348 2.998 3 2.998zm4.508-3c0 .552-.449 1-1 1h-1.754-1.754c-.551 0-1-.449-1-1v-.82h2.754 2.754zm1.755-3.819c0 .551-.448 1-1 1h-3.509-3.509c-.552 0-1-.449-1-1v-2.068c.232.058.47.097.719.097h3.79 3.79c.249 0 .487-.039.719-.097zm-9.299-4.971c0-1.318-.292-2.595-.868-3.793-.563-1.171-1.385-2.233-2.376-3.071-3.247-2.742-4.948-6.926-4.551-11.191.607-6.389 5.903-11.426 12.275-11.715l.31-.01.265.009c6.417.29 11.713 5.327 12.319 11.714.398 4.267-1.303 8.451-4.55 11.193-.991.838-1.813 1.9-2.376 3.071-.576 1.198-.868 2.475-.868 3.793 0 .551-.448 1-1 1h-3.79-3.79c-.552 0-1-.449-1-1z"/></g></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
+  <path d="m6.443,4.08L4.304.567,5.157.048l2.14,3.513-.854.52Zm13.557,7.92c0,2.323-1.01,4.528-2.771,6.051-.781.674-1.229,1.641-1.229,2.653v3.296h-8v-3.295c0-1.007-.456-1.982-1.252-2.675-2.062-1.796-3.058-4.497-2.661-7.227.512-3.521,3.457-6.36,7.003-6.753,2.307-.256,4.527.45,6.245,1.987,1.693,1.517,2.665,3.689,2.665,5.962Zm-5,8.704c0-.239.04-.471.077-.704h-6.156c.038.233.078.467.078.705v2.295h6v-2.296Zm4-8.704c0-1.988-.85-3.89-2.332-5.217-1.502-1.344-3.438-1.964-5.469-1.738-3.1.343-5.675,2.825-6.122,5.903-.348,2.391.522,4.757,2.327,6.328.553.481.963,1.076,1.234,1.724h2.861v-5.551c-1.14-.232-2-1.242-2-2.449h1c0,.827.673,1.5,1.5,1.5s1.5-.673,1.5-1.5h1c0,1.208-.86,2.217-2,2.449v5.551h2.856c.268-.645.672-1.234,1.218-1.706,1.542-1.332,2.426-3.262,2.426-5.294Zm.696-11.433l-.854-.52-2.14,3.513.854.52,2.14-3.513Zm3.86,4.342l-3.536,1.597.412.912,3.536-1.597-.412-.912ZM.031,5.821l3.536,1.597.412-.912L.443,4.909l-.412.912Z"/>
+</svg>

+ 4 - 1
frontend/public/icons/temperature.svg

@@ -1 +1,4 @@
-<svg id="Layer_1" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m36 39.47v-32.47a7 7 0 1 0 -14 0v32.47a13.26 13.26 0 1 0 14 0zm-7 22.59a11.32 11.32 0 0 1 -5.53-21.19 1 1 0 0 0 .53-.87v-33a5 5 0 1 1 10 0v33a1 1 0 0 0 .49.84 11.32 11.32 0 0 1 -5.49 21.22z"/><path d="m30 44.89v-30.25a1 1 0 1 0 -1.94 0v30.25a5.94 5.94 0 1 0 1.94 0zm-1 9.85a4 4 0 1 1 4-4 4 4 0 0 1 -4 4z"/><path d="m40.32 9.64h7a1 1 0 0 0 0-1.94h-7a1 1 0 1 0 0 1.94z"/><path d="m40.32 16.06h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 20.55h-7a1 1 0 1 0 0 1.93h7a1 1 0 1 0 0-1.93z"/><path d="m40.32 28.91h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 33.39h-7a1 1 0 0 0 0 1.94h7a1 1 0 1 0 0-1.94z"/></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
+  <path d="m12.5,15.051V5h-1v10.051c-1.14.232-2,1.242-2,2.449,0,1.379,1.121,2.5,2.5,2.5s2.5-1.121,2.5-2.5c0-1.208-.86-2.217-2-2.449Zm-.5,3.949c-.827,0-1.5-.673-1.5-1.5s.673-1.5,1.5-1.5,1.5.673,1.5,1.5-.673,1.5-1.5,1.5Zm4.5-6.181V4.5c0-2.481-2.019-4.5-4.5-4.5s-4.5,2.019-4.5,4.5v8.319c-1.627,1.561-2.32,3.805-1.859,6.049.508,2.472,2.506,4.476,4.972,4.987.459.096.92.143,1.376.143,1.495,0,2.942-.503,4.111-1.454,1.525-1.241,2.4-3.08,2.4-5.044,0-1.763-.727-3.456-2-4.681Zm-1.031,8.949c-1.292,1.05-2.989,1.454-4.653,1.108-2.081-.432-3.767-2.124-4.194-4.21-.405-1.968.235-3.933,1.713-5.258l.166-.148V4.5c0-1.93,1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5v8.761l.166.148c1.166,1.046,1.834,2.537,1.834,4.091,0,1.662-.74,3.218-2.031,4.269Z"/>
+</svg>

ファイルの差分が大きいため隠しています
+ 0 - 0
frontend/public/icons/ventilation.svg


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

@@ -61,6 +61,26 @@ export interface NozzleInfo {
   nozzle_diameter: string;  // e.g., "0.4"
   nozzle_diameter: string;  // e.g., "0.4"
 }
 }
 
 
+export interface PrintOptions {
+  // Core AI detectors
+  spaghetti_detector: boolean;
+  print_halt: boolean;
+  halt_print_sensitivity: string;  // "low", "medium", "high" - spaghetti sensitivity
+  first_layer_inspector: boolean;
+  printing_monitor: boolean;
+  buildplate_marker_detector: boolean;
+  allow_skip_parts: boolean;
+  // Additional AI detectors (decoded from cfg bitmask)
+  nozzle_clumping_detector: boolean;
+  nozzle_clumping_sensitivity: string;  // "low", "medium", "high"
+  pileup_detector: boolean;
+  pileup_sensitivity: string;  // "low", "medium", "high"
+  airprint_detector: boolean;
+  airprint_sensitivity: string;  // "low", "medium", "high"
+  auto_recovery_step_loss: boolean;
+  filament_tangle_detect: boolean;
+}
+
 export interface PrinterStatus {
 export interface PrinterStatus {
   id: number;
   id: number;
   name: string;
   name: string;
@@ -86,9 +106,11 @@ export interface PrinterStatus {
   ams_exists: boolean;
   ams_exists: boolean;
   vt_tray: AMSTray | null;  // Virtual tray / external spool
   vt_tray: AMSTray | null;  // Virtual tray / external spool
   sdcard: boolean;  // SD card inserted
   sdcard: boolean;  // SD card inserted
+  store_to_sdcard: boolean;  // Store sent files on SD card
   timelapse: boolean;  // Timelapse recording active
   timelapse: boolean;  // Timelapse recording active
   ipcam: boolean;  // Live view enabled
   ipcam: boolean;  // Live view enabled
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
+  print_options: PrintOptions | null;  // AI detection and print options
 }
 }
 
 
 export interface PrinterCreate {
 export interface PrinterCreate {
@@ -1188,4 +1210,29 @@ export const api = {
     request<ControlResponse>(`/printers/${printerId}/control/refresh`, {
     request<ControlResponse>(`/printers/${printerId}/control/refresh`, {
       method: 'POST',
       method: 'POST',
     }),
     }),
+
+  // Print Options (AI Detection)
+  setPrintOption: (
+    printerId: number,
+    moduleName: string,
+    enabled: boolean,
+    printHalt = true,
+    sensitivity = 'medium'
+  ) => {
+    const params = new URLSearchParams({
+      module_name: moduleName,
+      enabled: String(enabled),
+      print_halt: String(printHalt),
+      sensitivity,
+    });
+    return request<{
+      success: boolean;
+      module_name: string;
+      enabled: boolean;
+      print_halt: boolean;
+      sensitivity: string;
+    }>(`/printers/${printerId}/print-options?${params}`, {
+      method: 'POST',
+    });
+  },
 };
 };

+ 393 - 0
frontend/src/components/control/PrintOptionsModal.tsx

@@ -0,0 +1,393 @@
+import { useEffect, useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { Printer, PrinterStatus } from '../../api/client';
+import { X, Loader2, AlertTriangle, ChevronDown } from 'lucide-react';
+import { Card, CardContent } from '../Card';
+
+interface PrintOptionsModalProps {
+  printer: Printer;
+  status: PrinterStatus | null | undefined;
+  onClose: () => void;
+}
+
+// Checkbox component matching Bambu Studio style
+function Checkbox({
+  checked,
+  onChange,
+  disabled,
+  loading,
+}: {
+  checked: boolean;
+  onChange: (checked: boolean) => void;
+  disabled?: boolean;
+  loading?: boolean;
+}) {
+  return (
+    <button
+      onClick={() => !disabled && !loading && onChange(!checked)}
+      disabled={disabled || loading}
+      className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
+        checked
+          ? 'bg-bambu-green border-bambu-green'
+          : 'bg-transparent border-bambu-gray'
+      } ${disabled || loading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
+    >
+      {loading ? (
+        <Loader2 className="w-3 h-3 animate-spin text-white" />
+      ) : checked ? (
+        <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
+          <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+        </svg>
+      ) : null}
+    </button>
+  );
+}
+
+// Sensitivity dropdown component
+function SensitivityDropdown({
+  value,
+  onChange,
+  disabled,
+  loading,
+}: {
+  value: string;
+  onChange: (value: string) => void;
+  disabled?: boolean;
+  loading?: boolean;
+}) {
+  const [isOpen, setIsOpen] = useState(false);
+  const options = ['Low', 'Medium', 'High'];
+
+  const displayValue = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
+  const isDisabled = disabled || loading;
+
+  return (
+    <div className="relative">
+      <button
+        onClick={() => !isDisabled && setIsOpen(!isOpen)}
+        disabled={isDisabled}
+        className={`flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white min-w-[100px] justify-between ${
+          isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-bambu-gray'
+        }`}
+      >
+        {loading ? (
+          <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />
+        ) : (
+          <span>{displayValue}</span>
+        )}
+        <ChevronDown className="w-4 h-4 text-bambu-gray" />
+      </button>
+      {isOpen && !isDisabled && (
+        <>
+          <div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
+          <div className="absolute top-full left-0 mt-1 w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded shadow-lg z-20">
+            {options.map((option) => (
+              <button
+                key={option}
+                onClick={() => {
+                  onChange(option.toLowerCase());
+                  setIsOpen(false);
+                }}
+                className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                  value.toLowerCase() === option.toLowerCase() ? 'text-bambu-green' : 'text-white'
+                }`}
+              >
+                {option}
+              </button>
+            ))}
+          </div>
+        </>
+      )}
+    </div>
+  );
+}
+
+export function PrintOptionsModal({ printer, status, onClose }: PrintOptionsModalProps) {
+  const queryClient = useQueryClient();
+  const printOptions = status?.print_options;
+  const isConnected = status?.connected ?? false;
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Set print option mutation - track both UI and API module names
+  const setOptionMutation = useMutation({
+    mutationFn: ({
+      apiModuleName,
+      enabled,
+      printHalt,
+      sensitivity,
+    }: {
+      uiModuleName: string;  // The module name from the UI (for loading state) - accessed via variables
+      apiModuleName: string; // The module name sent to API (may differ due to firmware quirk)
+      enabled: boolean;
+      printHalt?: boolean;
+      sensitivity?: string;
+    }) => api.setPrintOption(printer.id, apiModuleName, enabled, printHalt, sensitivity),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
+    },
+  });
+
+  const handleToggle = (moduleName: string, enabled: boolean, sensitivity?: string) => {
+    const printHalt = enabled && sensitivity !== undefined && sensitivity !== 'never_halt';
+    setOptionMutation.mutate({
+      uiModuleName: moduleName,
+      apiModuleName: moduleName,
+      enabled,
+      printHalt,
+      sensitivity: sensitivity ?? 'medium'
+    });
+  };
+
+  const handleSensitivityChange = (moduleName: string, sensitivity: string) => {
+    // Spaghetti and Pileup sensitivities are linked in firmware
+    // For pileup, we send spaghetti_detector to set the shared sensitivity
+    // All other detectors use their own module name
+    let apiModuleName = moduleName;
+    if (moduleName === 'pileup_detector') {
+      // Pileup sensitivity is linked to spaghetti - use spaghetti_detector
+      apiModuleName = 'spaghetti_detector';
+    }
+
+    setOptionMutation.mutate({
+      uiModuleName: moduleName,
+      apiModuleName,
+      enabled: true,
+      printHalt: true,
+      sensitivity
+    });
+  };
+
+  // Check loading state using the UI module name (not API module name)
+  const isToggling = (moduleName: string) =>
+    setOptionMutation.isPending && setOptionMutation.variables?.uiModuleName === moduleName;
+
+  // Check if sensitivity is being changed for a specific detector
+  // This also returns true for linked detectors (e.g., spaghetti loading shows for pileup too)
+  const isSensitivityLoading = (moduleName: string) => {
+    if (!setOptionMutation.isPending || !setOptionMutation.variables?.sensitivity) return false;
+    const uiModule = setOptionMutation.variables.uiModuleName;
+    // Direct match
+    if (uiModule === moduleName) return true;
+    // Linked: spaghetti and pileup share sensitivity
+    if ((uiModule === 'spaghetti_detector' && moduleName === 'pileup_detector') ||
+        (uiModule === 'pileup_detector' && moduleName === 'spaghetti_detector')) return true;
+    return false;
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <Card className="w-full max-w-xl max-h-[90vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-0 flex flex-col h-full">
+          {/* Header */}
+          <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
+            <span className="text-sm font-medium text-white">Print Options</span>
+            <button
+              onClick={onClose}
+              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
+            >
+              <X className="w-4 h-4" />
+            </button>
+          </div>
+
+          {/* Content - Scrollable */}
+          <div className="flex-1 overflow-y-auto p-6 space-y-6">
+            {!isConnected && (
+              <div className="flex items-center gap-2 p-3 bg-red-500/20 border border-red-500/50 rounded text-red-400">
+                <AlertTriangle className="w-4 h-4" />
+                <span className="text-sm">Printer not connected. Options cannot be changed.</span>
+              </div>
+            )}
+
+            {!printOptions ? (
+              <div className="flex items-center gap-2 text-bambu-gray">
+                <AlertTriangle className="w-4 h-4" />
+                <span className="text-sm">Print options not available. Try refreshing.</span>
+              </div>
+            ) : (
+              <>
+                {/* AI Detections Section */}
+                <div>
+                  <h3 className="text-base font-semibold text-white mb-1">AI Detections</h3>
+                  <p className="text-xs text-bambu-gray mb-4">
+                    Printer will send assistant message or pause printing if any of the following problem is detected.
+                  </p>
+                  <div className="space-y-5">
+                    {/* Spaghetti Detection */}
+                    <div className="space-y-2">
+                      <div className="flex items-center gap-3">
+                        <Checkbox
+                          checked={printOptions.spaghetti_detector}
+                          onChange={(checked) => handleToggle('spaghetti_detector', checked, printOptions.halt_print_sensitivity)}
+                          disabled={!isConnected}
+                          loading={isToggling('spaghetti_detector')}
+                        />
+                        <span className="text-sm font-medium text-white">Spaghetti Detection</span>
+                      </div>
+                      <p className="text-xs text-bambu-gray ml-8">
+                        Detects spaghetti failure (scattered loose filament).
+                      </p>
+                      <div className="flex items-center gap-3 ml-8">
+                        <span className="text-xs text-bambu-gray">Pausing Sensitivity:</span>
+                        <SensitivityDropdown
+                          value={printOptions.halt_print_sensitivity || 'medium'}
+                          onChange={(value) => handleSensitivityChange('spaghetti_detector', value)}
+                          disabled={!isConnected || !printOptions.spaghetti_detector}
+                          loading={isSensitivityLoading('spaghetti_detector')}
+                        />
+                      </div>
+                    </div>
+
+                    {/* Purge Chute Pile-Up Detection */}
+                    <div className="space-y-2">
+                      <div className="flex items-center gap-3">
+                        <Checkbox
+                          checked={printOptions.pileup_detector}
+                          onChange={(checked) => handleToggle('pileup_detector', checked, printOptions.pileup_sensitivity)}
+                          disabled={!isConnected}
+                          loading={isToggling('pileup_detector')}
+                        />
+                        <span className="text-sm font-medium text-white">Purge Chute Pile-Up Detection</span>
+                      </div>
+                      <p className="text-xs text-bambu-gray ml-8">
+                        Monitors if the waste is piled up in the purge chute.
+                      </p>
+                      <div className="flex items-center gap-3 ml-8">
+                        <span className="text-xs text-bambu-gray">Pausing Sensitivity:</span>
+                        <SensitivityDropdown
+                          value={printOptions.pileup_sensitivity || 'medium'}
+                          onChange={(value) => handleSensitivityChange('pileup_detector', value)}
+                          disabled={!isConnected || !printOptions.pileup_detector}
+                          loading={isSensitivityLoading('pileup_detector')}
+                        />
+                      </div>
+                    </div>
+
+                    {/* Nozzle Clumping Detection */}
+                    <div className="space-y-2">
+                      <div className="flex items-center gap-3">
+                        <Checkbox
+                          checked={printOptions.nozzle_clumping_detector}
+                          onChange={(checked) => handleToggle('clump_detector', checked, printOptions.nozzle_clumping_sensitivity)}
+                          disabled={!isConnected}
+                          loading={isToggling('clump_detector')}
+                        />
+                        <span className="text-sm font-medium text-white">Nozzle Clumping Detection</span>
+                      </div>
+                      <p className="text-xs text-bambu-gray ml-8">
+                        Checks if the nozzle is clumping by filaments or other foreign objects.
+                      </p>
+                      <div className="flex items-center gap-3 ml-8">
+                        <span className="text-xs text-bambu-gray">Pausing Sensitivity:</span>
+                        <SensitivityDropdown
+                          value={printOptions.nozzle_clumping_sensitivity || 'medium'}
+                          onChange={(value) => handleSensitivityChange('clump_detector', value)}
+                          disabled={!isConnected || !printOptions.nozzle_clumping_detector}
+                          loading={isSensitivityLoading('clump_detector')}
+                        />
+                      </div>
+                    </div>
+
+                    {/* Air Printing Detection */}
+                    <div className="space-y-2">
+                      <div className="flex items-center gap-3">
+                        <Checkbox
+                          checked={printOptions.airprint_detector}
+                          onChange={(checked) => handleToggle('airprint_detector', checked, printOptions.airprint_sensitivity)}
+                          disabled={!isConnected}
+                          loading={isToggling('airprint_detector')}
+                        />
+                        <span className="text-sm font-medium text-white">Air Printing Detection</span>
+                      </div>
+                      <p className="text-xs text-bambu-gray ml-8">
+                        Detects air printing caused by nozzle clogging or filament grinding.
+                      </p>
+                      <div className="flex items-center gap-3 ml-8">
+                        <span className="text-xs text-bambu-gray">Pausing Sensitivity:</span>
+                        <SensitivityDropdown
+                          value={printOptions.airprint_sensitivity || 'medium'}
+                          onChange={(value) => handleSensitivityChange('airprint_detector', value)}
+                          disabled={!isConnected || !printOptions.airprint_detector}
+                          loading={isSensitivityLoading('airprint_detector')}
+                        />
+                      </div>
+                    </div>
+                  </div>
+                </div>
+
+                <hr className="border-bambu-dark-tertiary" />
+
+                {/* Build Plate Detection Section */}
+                <div>
+                  <h3 className="text-base font-semibold text-white mb-1">Build Plate Detection</h3>
+                  <p className="text-xs text-bambu-gray mb-4">
+                    Ensures the build plate type and placement are correct.
+                  </p>
+                  <div className="space-y-2">
+                    <div className="flex items-center gap-3">
+                      <Checkbox
+                        checked={printOptions.buildplate_marker_detector}
+                        onChange={(checked) => handleToggle('buildplate_marker_detector', checked)}
+                        disabled={!isConnected}
+                        loading={isToggling('buildplate_marker_detector')}
+                      />
+                      <span className="text-sm font-medium text-white">Type Detection</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray ml-8">
+                      Pauses printing when the detected build plate type does not match the selected one.
+                    </p>
+                  </div>
+                </div>
+
+                <hr className="border-bambu-dark-tertiary" />
+
+                {/* Other Options */}
+                <div className="space-y-5">
+                  {/* Auto-recover from step loss */}
+                  <div className="flex items-center gap-3">
+                    <Checkbox
+                      checked={printOptions.auto_recovery_step_loss}
+                      onChange={(checked) => handleToggle('auto_recovery_step_loss', checked)}
+                      disabled={!isConnected}
+                      loading={isToggling('auto_recovery_step_loss')}
+                    />
+                    <span className="text-sm font-medium text-white">Auto-recover from step loss</span>
+                  </div>
+
+                  {/* Store Sent Files on External Storage - Read-only (no MQTT command available) */}
+                  <div className="space-y-2">
+                    <div className="flex items-center gap-3">
+                      <Checkbox
+                        checked={status?.store_to_sdcard ?? false}
+                        onChange={() => {}}
+                        disabled={true}
+                      />
+                      <span className="text-sm font-medium text-white">Store Sent Files on External Storage</span>
+                      <span className="text-xs text-bambu-gray italic">(read-only)</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray ml-8">
+                      Save the printing files initiated from Bambu Studio, Bambu Handy and MakerWorld on External Storage.
+                      This option can only be changed on the printer's touchscreen.
+                    </p>
+                  </div>
+                </div>
+              </>
+            )}
+          </div>
+
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 15 - 1
frontend/src/pages/ControlPage.tsx

@@ -12,6 +12,7 @@ import { ExtruderControls } from '../components/control/ExtruderControls';
 import { AMSSectionDual } from '../components/control/AMSSectionDual';
 import { AMSSectionDual } from '../components/control/AMSSectionDual';
 import { CameraSettingsModal } from '../components/control/CameraSettingsModal';
 import { CameraSettingsModal } from '../components/control/CameraSettingsModal';
 import { PrinterPartsModal } from '../components/control/PrinterPartsModal';
 import { PrinterPartsModal } from '../components/control/PrinterPartsModal';
+import { PrintOptionsModal } from '../components/control/PrintOptionsModal';
 import { Loader2, WifiOff, Video, Webcam, Settings } from 'lucide-react';
 import { Loader2, WifiOff, Video, Webcam, Settings } from 'lucide-react';
 
 
 export function ControlPage() {
 export function ControlPage() {
@@ -19,6 +20,7 @@ export function ControlPage() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [showCameraSettings, setShowCameraSettings] = useState(false);
   const [showCameraSettings, setShowCameraSettings] = useState(false);
   const [showPrinterParts, setShowPrinterParts] = useState(false);
   const [showPrinterParts, setShowPrinterParts] = useState(false);
+  const [showPrintOptions, setShowPrintOptions] = useState(false);
 
 
   // Fetch all printers
   // Fetch all printers
   const { data: printers, isLoading: loadingPrinters } = useQuery({
   const { data: printers, isLoading: loadingPrinters } = useQuery({
@@ -178,7 +180,10 @@ export function ControlPage() {
                 >
                 >
                   Printer Parts
                   Printer Parts
                 </button>
                 </button>
-                <button className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark">
+                <button
+                  onClick={() => setShowPrintOptions(true)}
+                  className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark"
+                >
                   Print Options
                   Print Options
                 </button>
                 </button>
                 <button className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark">
                 <button className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark">
@@ -261,6 +266,15 @@ export function ControlPage() {
           onClose={() => setShowPrinterParts(false)}
           onClose={() => setShowPrinterParts(false)}
         />
         />
       )}
       )}
+
+      {/* Print Options Modal */}
+      {showPrintOptions && selectedPrinter && (
+        <PrintOptionsModal
+          printer={selectedPrinter}
+          status={selectedStatus}
+          onClose={() => setShowPrintOptions(false)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-DKrE4YAX.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-DaEsKk03.css


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-OvloHYxf.css


+ 2 - 1
static/icons/ams-settings.svg

@@ -1 +1,2 @@
-<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill="#000" fill-rule="evenodd"><path d="m2.25 6c0-.41421.33579-.75.75-.75h3.5c.41421 0 .75.33579.75.75s-.33579.75-.75.75h-3.5c-.41421 0-.75-.33579-.75-.75zm8.5 0c0-.41421.3358-.75.75-.75h9.5c.4142 0 .75.33579.75.75s-.3358.75-.75.75h-9.5c-.4142 0-.75-.33579-.75-.75z"/><path d="m9 4.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.79493 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.45507 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.45507-3.25-3.25z"/><path d="m21.75 12c0-.4142-.3358-.75-.75-.75h-2c-.4142 0-.75.3358-.75.75s.3358.75.75.75h2c.4142 0 .75-.3358.75-.75zm-7 0c0-.4142-.3358-.75-.75-.75h-5c-.41421 0-.75.3358-.75.75s.33579.75.75.75h5c.4142 0 .75-.3358.75-.75zm-9 0c0-.4142-.33579-.75-.75-.75h-2c-.41421 0-.75.3358-.75.75s.33579.75.75.75h2c.41421 0 .75-.3358.75-.75z"/><path d="m16.5 10.25c.9665 0 1.75.7835 1.75 1.75s-.7835 1.75-1.75 1.75-1.75-.7835-1.75-1.75.7835-1.75 1.75-1.75zm3.25 1.75c0-1.7949-1.4551-3.25-3.25-3.25s-3.25 1.4551-3.25 3.25 1.4551 3.25 3.25 3.25 3.25-1.4551 3.25-3.25z"/><path d="m2.25 18c0-.4142.33579-.75.75-.75h5c.41421 0 .75.3358.75.75s-.33579.75-.75.75h-5c-.41421 0-.75-.3358-.75-.75zm10 0c0-.4142.3358-.75.75-.75h8c.4142 0 .75.3358.75.75s-.3358.75-.75.75h-8c-.4142 0-.75-.3358-.75-.75z"/><path d="m10.5 16.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.7949 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.4551 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.4551-3.25-3.25z"/></g></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512"><g id="_01_align_center" data-name="01 align center"><path d="M15,24H9V20.487a9,9,0,0,1-2.849-1.646L3.107,20.6l-3-5.2L3.15,13.645a9.1,9.1,0,0,1,0-3.29L.107,8.6l3-5.2L6.151,5.159A9,9,0,0,1,9,3.513V0h6V3.513a9,9,0,0,1,2.849,1.646L20.893,3.4l3,5.2L20.85,10.355a9.1,9.1,0,0,1,0,3.29L23.893,15.4l-3,5.2-3.044-1.758A9,9,0,0,1,15,20.487Zm-4-2h2V18.973l.751-.194A6.984,6.984,0,0,0,16.994,16.9l.543-.553,2.623,1.515,1-1.732-2.62-1.513.206-.746a7.048,7.048,0,0,0,0-3.75l-.206-.746,2.62-1.513-1-1.732L17.537,7.649,16.994,7.1a6.984,6.984,0,0,0-3.243-1.875L13,5.027V2H11V5.027l-.751.194A6.984,6.984,0,0,0,7.006,7.1l-.543.553L3.84,6.134l-1,1.732L5.46,9.379l-.206.746a7.048,7.048,0,0,0,0,3.75l.206.746L2.84,16.134l1,1.732,2.623-1.515.543.553a6.984,6.984,0,0,0,3.243,1.875l.751.194Zm1-6a4,4,0,1,1,4-4A4,4,0,0,1,12,16Zm0-6a2,2,0,1,0,2,2A2,2,0,0,0,12,10Z"/></g></svg>

ファイルの差分が大きいため隠しています
+ 0 - 0
static/icons/chamber.svg


+ 2 - 1
static/icons/hotend.svg

@@ -1 +1,2 @@
-<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g id="Front_Heater" data-name="Front Heater"><path d="m33.14 29h2.86l-4-7-4 7h3l1.5 3.7a8.38 8.38 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m23.22 29h2.78l-4-7-4 7h3.06l1.5 3.7a8.35 8.35 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .24-1.39 8.4 8.4 0 0 1 .2-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m57.88 20.09a3 3 0 0 0 -1.49-1.84l-7.73-4.1a35.48 35.48 0 0 0 -33.32 0l-7.72 4.1a3 3 0 0 0 -1.22 4.2l9.72 17a3 3 0 0 0 2.6 1.55 3 3 0 0 0 1.39-.34 1 1 0 0 0 .42-1.35 1 1 0 0 0 -1.34-.42 1 1 0 0 1 -1.33-.39l-9.72-17a1 1 0 0 1 -.1-.81 1 1 0 0 1 .52-.69l7.72-4.1a33.46 33.46 0 0 1 31.44 0l7.73 4.1a1 1 0 0 1 .51.63 1 1 0 0 1 -.1.81l-9.72 17a1 1 0 0 1 -1.33.39l-.2-.11a10.82 10.82 0 0 0 -.36-6.72l-1.19-3h2.94l-4-7-4 7h2.9l1.5 3.7a8.38 8.38 0 0 1 -.93 8l-.66.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.64-.78a8.27 8.27 0 0 0 .84-1.3 3 3 0 0 0 4-1.17l9.72-17a3 3 0 0 0 .32-2.44z"/></g></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" width="512" height="512" viewBox="0 0 24 24"><path d="M20.5,24c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.113-.252,.409-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Zm-7,0c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.112-.252,.409-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Zm-7,0c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.112-.252,.41-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Z"/></svg>

+ 4 - 1
static/icons/lamp.svg

@@ -1 +1,4 @@
-<svg id="Ecommerce" enable-background="new 0 0 48 48" height="512" viewBox="0 0 48 48" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m25 7.03c0-.552-.447-1-1-1-5.514 0-10 4.486-10 10 0 .552.447 1 1 1s1-.448 1-1c0-4.411 3.589-8 8-8 .553 0 1-.448 1-1z"/><path d="m22.246 45.79h1.754 1.754c1.652 0 2.999-1.345 3-3v-1.099c1.032-.475 1.755-1.512 1.755-2.721v-3.72c.176-.382.281-.802.281-1.25 0-1.016.226-2 .671-2.927.441-.919 1.086-1.752 1.864-2.409 3.746-3.165 5.709-7.989 5.25-12.909-.7-7.375-6.813-13.189-14.265-13.525l-.31-.01-.354.011c-7.408.335-13.521 6.149-14.222 13.526-.458 4.917 1.505 9.742 5.251 12.906.778.658 1.423 1.491 1.864 2.41.445.927.671 1.911.671 2.927 0 .447.105.868.281 1.25v3.721c0 1.209.722 2.247 1.755 2.721v1.1c.001 1.653 1.348 2.998 3 2.998zm4.508-3c0 .552-.449 1-1 1h-1.754-1.754c-.551 0-1-.449-1-1v-.82h2.754 2.754zm1.755-3.819c0 .551-.448 1-1 1h-3.509-3.509c-.552 0-1-.449-1-1v-2.068c.232.058.47.097.719.097h3.79 3.79c.249 0 .487-.039.719-.097zm-9.299-4.971c0-1.318-.292-2.595-.868-3.793-.563-1.171-1.385-2.233-2.376-3.071-3.247-2.742-4.948-6.926-4.551-11.191.607-6.389 5.903-11.426 12.275-11.715l.31-.01.265.009c6.417.29 11.713 5.327 12.319 11.714.398 4.267-1.303 8.451-4.55 11.193-.991.838-1.813 1.9-2.376 3.071-.576 1.198-.868 2.475-.868 3.793 0 .551-.448 1-1 1h-3.79-3.79c-.552 0-1-.449-1-1z"/></g></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
+  <path d="m6.443,4.08L4.304.567,5.157.048l2.14,3.513-.854.52Zm13.557,7.92c0,2.323-1.01,4.528-2.771,6.051-.781.674-1.229,1.641-1.229,2.653v3.296h-8v-3.295c0-1.007-.456-1.982-1.252-2.675-2.062-1.796-3.058-4.497-2.661-7.227.512-3.521,3.457-6.36,7.003-6.753,2.307-.256,4.527.45,6.245,1.987,1.693,1.517,2.665,3.689,2.665,5.962Zm-5,8.704c0-.239.04-.471.077-.704h-6.156c.038.233.078.467.078.705v2.295h6v-2.296Zm4-8.704c0-1.988-.85-3.89-2.332-5.217-1.502-1.344-3.438-1.964-5.469-1.738-3.1.343-5.675,2.825-6.122,5.903-.348,2.391.522,4.757,2.327,6.328.553.481.963,1.076,1.234,1.724h2.861v-5.551c-1.14-.232-2-1.242-2-2.449h1c0,.827.673,1.5,1.5,1.5s1.5-.673,1.5-1.5h1c0,1.208-.86,2.217-2,2.449v5.551h2.856c.268-.645.672-1.234,1.218-1.706,1.542-1.332,2.426-3.262,2.426-5.294Zm.696-11.433l-.854-.52-2.14,3.513.854.52,2.14-3.513Zm3.86,4.342l-3.536,1.597.412.912,3.536-1.597-.412-.912ZM.031,5.821l3.536,1.597.412-.912L.443,4.909l-.412.912Z"/>
+</svg>

+ 4 - 1
static/icons/temperature.svg

@@ -1 +1,4 @@
-<svg id="Layer_1" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m36 39.47v-32.47a7 7 0 1 0 -14 0v32.47a13.26 13.26 0 1 0 14 0zm-7 22.59a11.32 11.32 0 0 1 -5.53-21.19 1 1 0 0 0 .53-.87v-33a5 5 0 1 1 10 0v33a1 1 0 0 0 .49.84 11.32 11.32 0 0 1 -5.49 21.22z"/><path d="m30 44.89v-30.25a1 1 0 1 0 -1.94 0v30.25a5.94 5.94 0 1 0 1.94 0zm-1 9.85a4 4 0 1 1 4-4 4 4 0 0 1 -4 4z"/><path d="m40.32 9.64h7a1 1 0 0 0 0-1.94h-7a1 1 0 1 0 0 1.94z"/><path d="m40.32 16.06h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 20.55h-7a1 1 0 1 0 0 1.93h7a1 1 0 1 0 0-1.93z"/><path d="m40.32 28.91h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 33.39h-7a1 1 0 0 0 0 1.94h7a1 1 0 1 0 0-1.94z"/></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
+  <path d="m12.5,15.051V5h-1v10.051c-1.14.232-2,1.242-2,2.449,0,1.379,1.121,2.5,2.5,2.5s2.5-1.121,2.5-2.5c0-1.208-.86-2.217-2-2.449Zm-.5,3.949c-.827,0-1.5-.673-1.5-1.5s.673-1.5,1.5-1.5,1.5.673,1.5,1.5-.673,1.5-1.5,1.5Zm4.5-6.181V4.5c0-2.481-2.019-4.5-4.5-4.5s-4.5,2.019-4.5,4.5v8.319c-1.627,1.561-2.32,3.805-1.859,6.049.508,2.472,2.506,4.476,4.972,4.987.459.096.92.143,1.376.143,1.495,0,2.942-.503,4.111-1.454,1.525-1.241,2.4-3.08,2.4-5.044,0-1.763-.727-3.456-2-4.681Zm-1.031,8.949c-1.292,1.05-2.989,1.454-4.653,1.108-2.081-.432-3.767-2.124-4.194-4.21-.405-1.968.235-3.933,1.713-5.258l.166-.148V4.5c0-1.93,1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5v8.761l.166.148c1.166,1.046,1.834,2.537,1.834,4.091,0,1.662-.74,3.218-2.031,4.269Z"/>
+</svg>

ファイルの差分が大きいため隠しています
+ 0 - 0
static/icons/ventilation.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="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-DVDHG02m.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DaEsKk03.css">
+    <script type="module" crossorigin src="/assets/index-DKrE4YAX.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-OvloHYxf.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません