Browse Source

Wired air condition, speed and light buttons

Martin Ziegler 5 tháng trước cách đây
mục cha
commit
9e638e2d0f

+ 61 - 0
backend/app/api/routes/printer_control.py

@@ -273,6 +273,35 @@ async def set_nozzle_temperature(
     )
 
 
+@router.post("/{printer_id}/control/temperature/chamber", response_model=ControlResponse)
+async def set_chamber_temperature(
+    printer_id: int,
+    request: TemperatureRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the chamber target temperature."""
+    await get_printer_or_404(printer_id, db)
+    client = get_mqtt_client_or_503(printer_id)
+
+    # Warn for high temperatures (chamber typically maxes around 60°C)
+    if request.target > 60 and not request.confirm_token:
+        token = _create_confirmation_token(printer_id, "chamber_temp")
+        return ConfirmationRequired(
+            token=token,
+            warning=f"Setting chamber to {request.target}°C is very high. Confirm?"
+        )
+
+    if request.target > 60:
+        if not _validate_confirmation_token(request.confirm_token, printer_id, "chamber_temp"):
+            raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
+
+    success = client.set_chamber_temperature(request.target)
+    return ControlResponse(
+        success=success,
+        message=f"Chamber temperature set to {request.target}°C" if success else "Failed to set chamber temperature"
+    )
+
+
 # =============================================================================
 # Speed Control Endpoint
 # =============================================================================
@@ -354,6 +383,38 @@ async def set_chamber_fan(
     )
 
 
+# =============================================================================
+# Air Conditioning Control Endpoint
+# =============================================================================
+
+class AirductModeRequest(BaseModel):
+    mode: str  # "cooling" or "heating"
+
+
+@router.post("/{printer_id}/control/airduct", response_model=ControlResponse)
+async def set_airduct_mode(
+    printer_id: int,
+    request: AirductModeRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Set air conditioning mode (cooling or heating).
+
+    - Cooling: Suitable for PLA/PETG/TPU, filters and cools chamber air
+    - Heating: Suitable for ABS/ASA/PC/PA, circulates and heats chamber air, closes top exhaust flap
+    """
+    await get_printer_or_404(printer_id, db)
+    client = get_mqtt_client_or_503(printer_id)
+
+    if request.mode not in ("cooling", "heating"):
+        raise HTTPException(status_code=400, detail="Mode must be 'cooling' or 'heating'")
+
+    success = client.set_airduct_mode(request.mode)
+    return ControlResponse(
+        success=success,
+        message=f"Air conditioning set to {request.mode}" if success else "Failed to set air conditioning mode"
+    )
+
+
 # =============================================================================
 # Light Control Endpoint
 # =============================================================================

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

@@ -241,6 +241,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         stg_cur=state.stg_cur,
         stg_cur_name=get_stage_name(state.stg_cur) if state.stg_cur >= 0 else None,
         stg=state.stg,
+        airduct_mode=state.airduct_mode,
+        speed_level=state.speed_level,
+        chamber_light=state.chamber_light,
     )
 
 

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

@@ -112,3 +112,9 @@ class PrinterStatus(BaseModel):
     stg_cur: int = -1  # Current stage number (-1 = not calibrating)
     stg_cur_name: str | None = None  # Human-readable current stage name
     stg: list[int] = []  # List of stage numbers in calibration sequence
+    # Air conditioning mode (0=cooling, 1=heating)
+    airduct_mode: int = 0
+    # Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
+    speed_level: int = 2
+    # Chamber light on/off
+    chamber_light: bool = False

+ 93 - 13
backend/app/services/bambu_mqtt.py

@@ -103,6 +103,12 @@ class PrinterState:
     # Calibration stage tracking (from stg_cur and stg fields)
     stg_cur: int = -1  # Current stage index (-1 = not calibrating)
     stg: list = field(default_factory=list)  # List of stages to execute
+    # Air conditioning mode (0=cooling, 1=heating)
+    airduct_mode: int = 0
+    # Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
+    speed_level: int = 2
+    # Chamber light on/off
+    chamber_light: bool = False
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -781,6 +787,13 @@ class BambuMQTTClient:
                 # Parse chamber temp from device.ctc.info.temp if not already set
                 ctc_data = device.get("ctc", {})
                 ctc_info = ctc_data.get("info", {})
+                # Parse airduct mode (0=cooling, 1=heating)
+                airduct_data = device.get("airduct", {})
+                if "modeCur" in airduct_data:
+                    new_mode = airduct_data["modeCur"]
+                    if new_mode != self.state.airduct_mode:
+                        logger.info(f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}")
+                    self.state.airduct_mode = new_mode
                 if "temp" in ctc_info and "chamber" not in temps:
                     temps["chamber"] = float(ctc_info["temp"])
                 # Parse chamber target from ctc.info.target if available
@@ -860,6 +873,26 @@ class BambuMQTTClient:
             else:
                 self.state.ipcam = ipcam_data is True
 
+        # Parse print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
+        if "spd_lvl" in data:
+            new_speed = data["spd_lvl"]
+            if new_speed != self.state.speed_level:
+                logger.info(f"[{self.serial_number}] speed_level changed: {self.state.speed_level} -> {new_speed}")
+            self.state.speed_level = new_speed
+
+        # Parse chamber light status from lights_report
+        if "lights_report" in data:
+            lights = data["lights_report"]
+            logger.debug(f"[{self.serial_number}] lights_report: {lights}")
+            if isinstance(lights, list):
+                for light in lights:
+                    if isinstance(light, dict) and light.get("node") == "chamber_light":
+                        new_light_state = light.get("mode") == "on"
+                        if new_light_state != self.state.chamber_light:
+                            logger.info(f"[{self.serial_number}] chamber_light changed: {self.state.chamber_light} -> {new_light_state}")
+                        self.state.chamber_light = new_light_state
+                        break
+
         # Parse nozzle hardware info (single nozzle printers)
         if "nozzle_type" in data:
             self.state.nozzles[0].nozzle_type = str(data["nozzle_type"])
@@ -1772,6 +1805,18 @@ class BambuMQTTClient:
             logger.info(f"[{self.serial_number}] Tracking LEFT nozzle target locally: {target}°C")
         return result
 
+    def set_chamber_temperature(self, target: int) -> bool:
+        """Set the chamber target temperature.
+
+        Args:
+            target: Target temperature in Celsius (0 to turn off heating)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        # M141 sets chamber temperature
+        return self.send_gcode(f"M141 S{target}")
+
     def set_print_speed(self, mode: int) -> bool:
         """Set the print speed mode.
 
@@ -1829,6 +1874,37 @@ class BambuMQTTClient:
         """Set chamber fan speed (0-255)."""
         return self.set_fan_speed(3, speed)
 
+    def set_airduct_mode(self, mode: str) -> bool:
+        """Set air conditioning mode (cooling or heating).
+
+        Args:
+            mode: "cooling" (modeId=0) or "heating" (modeId=1)
+                - Cooling: Suitable for PLA/PETG/TPU, filters and cools chamber air
+                - Heating: Suitable for ABS/ASA/PC/PA, circulates and heats chamber air,
+                           closes top exhaust flap
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set airduct mode: not connected")
+            return False
+
+        self._sequence_id += 1
+        mode_id = 0 if mode == "cooling" else 1
+        command = {
+            "print": {
+                "command": "set_airduct",
+                "modeId": mode_id,
+                "sequence_id": str(self._sequence_id),
+                "submode": -1
+            }
+        }
+        # Use QoS 1 for reliable delivery
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(f"[{self.serial_number}] Set airduct mode to {mode} (modeId={mode_id}, seq={self._sequence_id})")
+        return True
+
     def set_chamber_light(self, on: bool) -> bool:
         """Turn chamber light on or off.
 
@@ -1842,20 +1918,24 @@ class BambuMQTTClient:
             logger.warning(f"[{self.serial_number}] Cannot set chamber light: not connected")
             return False
 
-        command = {
-            "system": {
-                "command": "ledctrl",
-                "led_node": "chamber_light",
-                "led_mode": "on" if on else "off",
-                "led_on_time": 500,
-                "led_off_time": 500,
-                "loop_times": 0,
-                "interval_time": 0,
-                "sequence_id": "0"
+        mode = "on" if on else "off"
+        # Control both chamber lights (some printers like H2D have two)
+        for led_node in ["chamber_light", "chamber_light2"]:
+            self._sequence_id += 1
+            command = {
+                "system": {
+                    "command": "ledctrl",
+                    "led_node": led_node,
+                    "led_mode": mode,
+                    "led_on_time": 500,
+                    "led_off_time": 500,
+                    "loop_times": 0,
+                    "interval_time": 0,
+                    "sequence_id": str(self._sequence_id)
+                }
             }
-        }
-        self._client.publish(self.topic_publish, json.dumps(command))
-        logger.info(f"[{self.serial_number}] Set chamber light {'on' if on else 'off'}")
+            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        logger.info(f"[{self.serial_number}] Set chamber lights {'on' if on else 'off'} (seq={self._sequence_id})")
         return True
 
     def home_axes(self, axes: str = "XYZ") -> bool:

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

@@ -115,6 +115,12 @@ export interface PrinterStatus {
   stg_cur: number;  // Current stage number (-1 = not calibrating)
   stg_cur_name: string | null;  // Human-readable current stage name
   stg: number[];  // List of stage numbers in calibration sequence
+  // Air conditioning mode (0=cooling, 1=heating)
+  airduct_mode: number;
+  // Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
+  speed_level: number;
+  // Chamber light on/off
+  chamber_light: boolean;
 }
 
 export interface PrinterCreate {
@@ -1138,6 +1144,11 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ target, nozzle, confirm_token: confirmToken }),
     }),
+  setChamberTemperature: (printerId: number, target: number, confirmToken?: string) =>
+    request<ControlResult>(`/printers/${printerId}/control/temperature/chamber`, {
+      method: 'POST',
+      body: JSON.stringify({ target, confirm_token: confirmToken }),
+    }),
   setPrintSpeed: (printerId: number, mode: number) =>
     request<ControlResponse>(`/printers/${printerId}/control/speed`, {
       method: 'POST',
@@ -1158,6 +1169,11 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ speed }),
     }),
+  setAirductMode: (printerId: number, mode: 'cooling' | 'heating') =>
+    request<ControlResponse>(`/printers/${printerId}/control/airduct`, {
+      method: 'POST',
+      body: JSON.stringify({ mode }),
+    }),
   setChamberLight: (printerId: number, on: boolean) =>
     request<ControlResponse>(`/printers/${printerId}/control/light`, {
       method: 'POST',

+ 451 - 0
frontend/src/components/control/AirConditionModal.tsx

@@ -0,0 +1,451 @@
+import { useState, useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { Printer, PrinterStatus } from '../../api/client';
+import { X, Minus, Plus, Wind, Flame, AlertTriangle } from 'lucide-react';
+
+interface AirConditionModalProps {
+  printer: Printer;
+  status: PrinterStatus | null | undefined;
+  onClose: () => void;
+}
+
+type Mode = 'cooling' | 'heating';
+
+// Toggle switch component
+function Toggle({
+  checked,
+  onChange,
+  disabled,
+}: {
+  checked: boolean;
+  onChange: (checked: boolean) => void;
+  disabled?: boolean;
+}) {
+  return (
+    <button
+      onClick={() => !disabled && onChange(!checked)}
+      disabled={disabled}
+      className={`w-11 h-6 rounded-full relative transition-all shadow-inner border ${
+        checked ? 'bg-bambu-green border-bambu-green' : 'bg-bambu-dark-tertiary border-bambu-dark-tertiary'
+      } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
+    >
+      <div
+        className={`w-5 h-5 rounded-full bg-white shadow-md absolute top-0.5 transition-transform ${
+          checked ? 'translate-x-5' : 'translate-x-0.5'
+        }`}
+      />
+    </button>
+  );
+}
+
+// Fan control card component
+function FanCard({
+  name,
+  icon,
+  speed,
+  enabled,
+  onToggle,
+  onSpeedChange,
+  disabled,
+  showSpeedControl = true,
+}: {
+  name: string;
+  icon: React.ReactNode;
+  speed: number;
+  enabled: boolean;
+  onToggle: (enabled: boolean) => void;
+  onSpeedChange: (speed: number) => void;
+  disabled?: boolean;
+  showSpeedControl?: boolean;
+}) {
+  const increment = () => {
+    const newSpeed = Math.min(100, speed + 10);
+    onSpeedChange(newSpeed);
+  };
+
+  const decrement = () => {
+    const newSpeed = Math.max(0, speed - 10);
+    onSpeedChange(newSpeed);
+  };
+
+  return (
+    <div className="bg-bambu-dark rounded-xl p-4 shadow-lg border border-bambu-dark-tertiary/50">
+      {/* Header */}
+      <div className="flex items-center justify-between mb-4">
+        <div className="flex items-center gap-2.5">
+          <div className="w-8 h-8 rounded-lg bg-bambu-dark-tertiary flex items-center justify-center">
+            {icon}
+          </div>
+          <span className="text-white font-medium">{name}</span>
+        </div>
+        {showSpeedControl && (
+          <Toggle checked={enabled} onChange={onToggle} disabled={disabled} />
+        )}
+      </div>
+
+      {/* Controls or Status */}
+      {showSpeedControl ? (
+        <div className="flex items-center justify-between bg-bambu-dark-secondary rounded-lg p-2">
+          <button
+            onClick={decrement}
+            disabled={disabled || !enabled}
+            className="w-9 h-9 rounded-lg bg-bambu-dark-tertiary flex items-center justify-center text-white hover:bg-bambu-dark transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
+          >
+            <Minus className="w-4 h-4" />
+          </button>
+          <div className="flex-1 text-center">
+            <span className={`text-xl font-semibold ${enabled ? 'text-white' : 'text-bambu-gray'}`}>
+              {speed}%
+            </span>
+          </div>
+          <button
+            onClick={increment}
+            disabled={disabled || !enabled}
+            className="w-9 h-9 rounded-lg bg-bambu-dark-tertiary flex items-center justify-center text-white hover:bg-bambu-dark transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
+          >
+            <Plus className="w-4 h-4" />
+          </button>
+        </div>
+      ) : (
+        <div className="p-3 text-center">
+          <span className={`text-xl font-semibold ${enabled ? 'text-bambu-green' : 'text-bambu-green'}`}>
+            {enabled ? 'Auto' : 'Off'}
+          </span>
+        </div>
+      )}
+    </div>
+  );
+}
+
+// Warning dialog component
+function PrintWarningDialog({
+  onConfirm,
+  onCancel,
+}: {
+  onConfirm: () => void;
+  onCancel: () => void;
+}) {
+  return (
+    <div className="absolute inset-0 bg-black/80 flex items-center justify-center z-10 rounded-2xl">
+      <div className="bg-bambu-dark-secondary rounded-xl p-5 m-4 max-w-sm border border-bambu-dark-tertiary shadow-xl">
+        <div className="flex items-center gap-3 mb-4">
+          <div className="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center flex-shrink-0">
+            <AlertTriangle className="w-5 h-5 text-yellow-500" />
+          </div>
+          <div>
+            <h3 className="text-white font-semibold">Print in Progress</h3>
+            <p className="text-sm text-bambu-gray">The printer is currently controlling air conditioning.</p>
+          </div>
+        </div>
+        <p className="text-sm text-bambu-gray mb-5">
+          Changing these settings during a print may affect print quality. Are you sure you want to continue?
+        </p>
+        <div className="flex gap-3">
+          <button
+            onClick={onCancel}
+            className="flex-1 py-2.5 px-4 rounded-lg font-medium text-sm bg-bambu-dark-tertiary text-white hover:bg-bambu-dark transition-colors"
+          >
+            Cancel
+          </button>
+          <button
+            onClick={onConfirm}
+            className="flex-1 py-2.5 px-4 rounded-lg font-medium text-sm bg-yellow-600 text-white hover:bg-yellow-700 transition-colors"
+          >
+            Continue
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function AirConditionModal({ printer, status, onClose }: AirConditionModalProps) {
+  const isConnected = status?.connected ?? false;
+  const isPrinting = status?.state === 'RUNNING' || status?.state === 'PRINTING';
+  const isDisabled = !isConnected;
+
+  // Initialize mode from printer status (0=cooling, 1=heating)
+  const initialMode: Mode = (status?.airduct_mode ?? 0) === 1 ? 'heating' : 'cooling';
+  const [mode, setMode] = useState<Mode>(initialMode);
+  const [showPrintWarning, setShowPrintWarning] = useState(false);
+  const [pendingAction, setPendingAction] = useState<(() => void) | null>(null);
+
+  // Fan states
+  const [partFanEnabled, setPartFanEnabled] = useState(false);
+  const [partFanSpeed, setPartFanSpeed] = useState(0);
+  const [auxFanEnabled, setAuxFanEnabled] = useState(false);
+  const [auxFanSpeed, setAuxFanSpeed] = useState(0);
+  const [exhaustFanEnabled, setExhaustFanEnabled] = useState(false);
+  const [exhaustFanSpeed, setExhaustFanSpeed] = useState(0);
+
+  // Mutation for airduct mode
+  const airductMutation = useMutation({
+    mutationFn: (newMode: Mode) => api.setAirductMode(printer.id, newMode),
+  });
+
+  // Mutations for fan control
+  const partFanMutation = useMutation({
+    mutationFn: (speed: number) => api.setPartFan(printer.id, speed),
+  });
+
+  const auxFanMutation = useMutation({
+    mutationFn: (speed: number) => api.setAuxFan(printer.id, speed),
+  });
+
+  const chamberFanMutation = useMutation({
+    mutationFn: (speed: number) => api.setChamberFan(printer.id, speed),
+  });
+
+  // Wrapper to check for print warning before executing action
+  const withPrintWarning = (action: () => void) => {
+    if (isPrinting) {
+      setPendingAction(() => action);
+      setShowPrintWarning(true);
+    } else {
+      action();
+    }
+  };
+
+  const handleWarningConfirm = () => {
+    if (pendingAction) {
+      pendingAction();
+      setPendingAction(null);
+    }
+    setShowPrintWarning(false);
+  };
+
+  const handleWarningCancel = () => {
+    setPendingAction(null);
+    setShowPrintWarning(false);
+  };
+
+  // Handle mode change
+  const handleModeChange = (newMode: Mode) => {
+    if (newMode === mode) return;
+
+    withPrintWarning(() => {
+      setMode(newMode);
+      airductMutation.mutate(newMode);
+    });
+  };
+
+  const handlePartFanToggle = (enabled: boolean) => {
+    withPrintWarning(() => {
+      setPartFanEnabled(enabled);
+      if (!enabled) {
+        setPartFanSpeed(0);
+        partFanMutation.mutate(0);
+      }
+    });
+  };
+
+  const handlePartFanSpeed = (speed: number) => {
+    withPrintWarning(() => {
+      setPartFanSpeed(speed);
+      setPartFanEnabled(speed > 0);
+      partFanMutation.mutate(speed);
+    });
+  };
+
+  const handleAuxFanToggle = (enabled: boolean) => {
+    withPrintWarning(() => {
+      setAuxFanEnabled(enabled);
+      if (!enabled) {
+        setAuxFanSpeed(0);
+        auxFanMutation.mutate(0);
+      }
+    });
+  };
+
+  const handleAuxFanSpeed = (speed: number) => {
+    withPrintWarning(() => {
+      setAuxFanSpeed(speed);
+      setAuxFanEnabled(speed > 0);
+      auxFanMutation.mutate(speed);
+    });
+  };
+
+  const handleExhaustFanToggle = (enabled: boolean) => {
+    withPrintWarning(() => {
+      setExhaustFanEnabled(enabled);
+      if (!enabled) {
+        setExhaustFanSpeed(0);
+        chamberFanMutation.mutate(0);
+      }
+    });
+  };
+
+  const handleExhaustFanSpeed = (speed: number) => {
+    withPrintWarning(() => {
+      setExhaustFanSpeed(speed);
+      setExhaustFanEnabled(speed > 0);
+      chamberFanMutation.mutate(speed);
+    });
+  };
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        if (showPrintWarning) {
+          handleWarningCancel();
+        } else {
+          onClose();
+        }
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, showPrintWarning]);
+
+  const modeDescriptions = {
+    cooling: 'Suitable for PLA, PETG, TPU. Filters and cools the chamber air.',
+    heating: 'Suitable for ABS, ASA, PC, PA. Circulates and heats chamber air.',
+  };
+
+  const FanIcon = () => (
+    <img src="/icons/ventilation.svg" alt="" className="w-5 h-5 icon-theme" />
+  );
+
+  const HeatIcon = () => (
+    <img src="/icons/chamber.svg" alt="" className="w-5 h-5 icon-theme" />
+  );
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="relative w-full max-w-lg bg-bambu-dark-secondary rounded-2xl shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Print Warning Overlay */}
+        {showPrintWarning && (
+          <PrintWarningDialog
+            onConfirm={handleWarningConfirm}
+            onCancel={handleWarningCancel}
+          />
+        )}
+
+        {/* Header */}
+        <div className="flex items-center justify-between px-5 py-4 bg-bambu-dark border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <div className="w-8 h-8 rounded-lg bg-bambu-green/20 flex items-center justify-center">
+              <img src="/icons/ventilation.svg" alt="" className="w-5 h-5 icon-green" />
+            </div>
+            <span className="text-base font-semibold text-white">Air Condition</span>
+          </div>
+          <button
+            onClick={onClose}
+            className="w-8 h-8 rounded-lg flex items-center justify-center text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-5 space-y-5">
+          {/* Print Warning Banner */}
+          {isPrinting && (
+            <div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
+              <AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0" />
+              <span className="text-xs text-yellow-500">
+                Print in progress. Changes may affect print quality.
+              </span>
+            </div>
+          )}
+
+          {/* Mode Toggle */}
+          <div className="bg-bambu-dark rounded-xl p-1.5 flex gap-1.5 shadow-inner">
+            <button
+              onClick={() => handleModeChange('cooling')}
+              disabled={isDisabled || airductMutation.isPending}
+              className={`flex-1 py-3 px-4 rounded-lg flex items-center justify-center gap-2 font-medium transition-all disabled:opacity-50 ${
+                mode === 'cooling'
+                  ? 'bg-bambu-green text-white shadow-lg'
+                  : 'text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+              }`}
+            >
+              <Wind className="w-4 h-4" />
+              Cooling
+            </button>
+            <button
+              onClick={() => handleModeChange('heating')}
+              disabled={isDisabled || airductMutation.isPending}
+              className={`flex-1 py-3 px-4 rounded-lg flex items-center justify-center gap-2 font-medium transition-all disabled:opacity-50 ${
+                mode === 'heating'
+                  ? 'bg-bambu-green text-white shadow-lg'
+                  : 'text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+              }`}
+            >
+              <Flame className="w-4 h-4" />
+              Heating
+            </button>
+          </div>
+
+          {/* Mode Description */}
+          <p className="text-sm text-bambu-gray text-center px-2">{modeDescriptions[mode]}</p>
+
+          {/* Separator */}
+          <div className="border-t border-bambu-dark-tertiary" />
+
+          {/* Fan Controls Grid */}
+          <div className="grid grid-cols-2 gap-3">
+            {/* Part Fan - always has speed control */}
+            <FanCard
+              name="Part"
+              icon={<FanIcon />}
+              speed={partFanSpeed}
+              enabled={partFanEnabled}
+              onToggle={handlePartFanToggle}
+              onSpeedChange={handlePartFanSpeed}
+              disabled={isDisabled}
+              showSpeedControl={true}
+            />
+
+            {/* Aux Fan */}
+            <FanCard
+              name="Aux"
+              icon={<FanIcon />}
+              speed={auxFanSpeed}
+              enabled={auxFanEnabled}
+              onToggle={handleAuxFanToggle}
+              onSpeedChange={handleAuxFanSpeed}
+              disabled={isDisabled}
+              showSpeedControl={mode === 'cooling'}
+            />
+
+            {/* Exhaust Fan */}
+            <FanCard
+              name="Exhaust"
+              icon={<FanIcon />}
+              speed={exhaustFanSpeed}
+              enabled={exhaustFanEnabled}
+              onToggle={handleExhaustFanToggle}
+              onSpeedChange={handleExhaustFanSpeed}
+              disabled={isDisabled}
+              showSpeedControl={mode === 'cooling'}
+            />
+
+            {/* Heat Status */}
+            <div className="bg-bambu-dark rounded-xl p-4 shadow-lg border border-bambu-dark-tertiary/50">
+              <div className="flex items-center gap-2.5 mb-4">
+                <div className="w-8 h-8 rounded-lg bg-bambu-dark-tertiary flex items-center justify-center">
+                  <HeatIcon />
+                </div>
+                <span className="text-white font-medium">Heat</span>
+              </div>
+              <div className="p-3 text-center">
+                <span className="text-xl font-semibold text-bambu-green">
+                  {mode === 'heating' ? 'Auto' : 'Off'}
+                </span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 133 - 0
frontend/src/components/control/SpeedModal.tsx

@@ -0,0 +1,133 @@
+import { useState, useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { Printer, PrinterStatus } from '../../api/client';
+import { X, AlertTriangle } from 'lucide-react';
+
+interface SpeedModalProps {
+  printer: Printer;
+  status: PrinterStatus | null | undefined;
+  onClose: () => void;
+}
+
+type SpeedMode = 1 | 2 | 3 | 4;
+
+interface SpeedOption {
+  mode: SpeedMode;
+  name: string;
+  description: string;
+  percentage: string;
+}
+
+const SPEED_OPTIONS: SpeedOption[] = [
+  { mode: 1, name: 'Silent', description: 'Quieter printing, slower speed', percentage: '50%' },
+  { mode: 2, name: 'Standard', description: 'Balanced speed and quality', percentage: '100%' },
+  { mode: 3, name: 'Sport', description: 'Faster printing, moderate noise', percentage: '124%' },
+  { mode: 4, name: 'Ludicrous', description: 'Maximum speed', percentage: '166%' },
+];
+
+export function SpeedModal({ printer, status, onClose }: SpeedModalProps) {
+  const isConnected = status?.connected ?? false;
+  const isPrinting = status?.state === 'RUNNING' || status?.state === 'PRINTING';
+  // Speed can only be changed during a print
+  const isDisabled = !isConnected || !isPrinting;
+
+  // Initialize from printer status
+  const initialSpeed = (status?.speed_level ?? 2) as SpeedMode;
+  const [selectedSpeed, setSelectedSpeed] = useState<SpeedMode>(initialSpeed);
+
+  const speedMutation = useMutation({
+    mutationFn: (mode: number) => api.setPrintSpeed(printer.id, mode),
+  });
+
+  const handleSpeedChange = (mode: SpeedMode) => {
+    if (mode === selectedSpeed || isDisabled) return;
+    setSelectedSpeed(mode);
+    speedMutation.mutate(mode);
+  };
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        onClose();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="relative w-full max-w-md bg-bambu-dark-secondary rounded-2xl shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-5 py-4 bg-bambu-dark border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <div className="w-8 h-8 rounded-lg bg-bambu-green/20 flex items-center justify-center">
+              <img src="/icons/speed.svg" alt="" className="w-5 h-5 icon-green" />
+            </div>
+            <span className="text-base font-semibold text-white">Print Speed</span>
+          </div>
+          <button
+            onClick={onClose}
+            className="w-8 h-8 rounded-lg flex items-center justify-center text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-5 space-y-4">
+          {/* Info Banner */}
+          {!isPrinting ? (
+            <div className="flex items-center gap-2 p-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
+              <AlertTriangle className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+              <span className="text-xs text-bambu-gray">
+                No print in progress. Speed can only be changed during printing.
+              </span>
+            </div>
+          ) : (
+            <div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
+              <AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0" />
+              <span className="text-xs text-yellow-500">
+                Speed changes take effect immediately.
+              </span>
+            </div>
+          )}
+
+          {/* Speed Options */}
+          <div className="space-y-2">
+            {SPEED_OPTIONS.map((option) => (
+              <button
+                key={option.mode}
+                onClick={() => handleSpeedChange(option.mode)}
+                disabled={isDisabled || speedMutation.isPending}
+                className={`w-full p-4 rounded-xl flex items-center justify-between transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
+                  selectedSpeed === option.mode
+                    ? 'bg-bambu-green text-white shadow-lg'
+                    : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary'
+                }`}
+              >
+                <div className="flex flex-col items-start">
+                  <span className="font-medium">{option.name}</span>
+                  <span className={`text-xs ${selectedSpeed === option.mode ? 'text-white/80' : 'text-bambu-gray'}`}>
+                    {option.description}
+                  </span>
+                </div>
+                <span className={`text-lg font-semibold ${selectedSpeed === option.mode ? 'text-white' : 'text-bambu-gray'}`}>
+                  {option.percentage}
+                </span>
+              </button>
+            ))}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 69 - 28
frontend/src/components/control/TemperatureColumn.tsx

@@ -1,7 +1,9 @@
 import { useState } from 'react';
 import { useMutation } from '@tanstack/react-query';
 import { api } from '../../api/client';
-import type { PrinterStatus } from '../../api/client';
+import type { Printer, PrinterStatus } from '../../api/client';
+import { AirConditionModal } from './AirConditionModal';
+import { SpeedModal } from './SpeedModal';
 
 interface Temperatures {
   bed?: number;
@@ -19,33 +21,38 @@ interface Temperatures {
 }
 
 interface TemperatureColumnProps {
-  printerId: number;
+  printer: Printer;
   status: PrinterStatus | null | undefined;
-  nozzleCount: number;
   disabled?: boolean;
 }
 
-type EditingField = 'nozzle' | 'nozzle_2' | 'bed' | null;
+type EditingField = 'nozzle' | 'nozzle_2' | 'bed' | 'chamber' | null;
 
-export function TemperatureColumn({ printerId, status, nozzleCount, disabled = false }: TemperatureColumnProps) {
+export function TemperatureColumn({ printer, status, disabled = false }: TemperatureColumnProps) {
   const temps = (status?.temperatures ?? {}) as Temperatures;
-  const isDualNozzle = nozzleCount > 1;
+  const isDualNozzle = printer.nozzle_count > 1;
   const isConnected = (status?.connected ?? false) && !disabled;
 
   const [editing, setEditing] = useState<EditingField>(null);
   const [editValue, setEditValue] = useState('');
+  const [showAirConditionModal, setShowAirConditionModal] = useState(false);
+  const [showSpeedModal, setShowSpeedModal] = useState(false);
 
   const bedMutation = useMutation({
-    mutationFn: (target: number) => api.setBedTemperature(printerId, target),
+    mutationFn: (target: number) => api.setBedTemperature(printer.id, target),
   });
 
   const nozzleMutation = useMutation({
     mutationFn: ({ target, nozzle }: { target: number; nozzle: number }) =>
-      api.setNozzleTemperature(printerId, target, nozzle),
+      api.setNozzleTemperature(printer.id, target, nozzle),
+  });
+
+  const chamberMutation = useMutation({
+    mutationFn: (target: number) => api.setChamberTemperature(printer.id, target),
   });
 
   const lightMutation = useMutation({
-    mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
+    mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),
   });
 
   const startEditing = (field: EditingField, currentValue: number) => {
@@ -76,6 +83,8 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
       // nozzle_2 field = RIGHT nozzle display
       // H2D: RIGHT is T0/default (index 0)
       nozzleMutation.mutate({ target, nozzle: 0 });
+    } else if (editing === 'chamber') {
+      chamberMutation.mutate(target);
     }
     cancelEditing();
   };
@@ -132,6 +141,7 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
   };
 
   return (
+    <>
     <div className="flex flex-col justify-evenly min-w-[150px] pr-5 border-r border-bambu-dark-tertiary">
       {/* Nozzle 1 (Left) */}
       <div className="flex items-center gap-1.5">
@@ -183,38 +193,69 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
         {renderTargetTemp('bed', temps.bed_target ?? 0)}
       </div>
 
-      {/* Chamber - read only (target set by print file or display) */}
+      {/* Chamber - editable target */}
       <div className="flex items-center gap-1.5">
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
           <img src="/icons/chamber.svg" alt="" className={`w-5 ${isChamberHeating ? 'icon-heating' : 'icon-theme'}`} />
         </div>
         {isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
         <span className="text-lg font-medium text-white">{Math.round(temps.chamber ?? 0)}</span>
-        <span className="text-sm text-bambu-gray">/{Math.round(temps.chamber_target ?? 0)} °C</span>
+        {renderTargetTemp('chamber', temps.chamber_target ?? 0)}
       </div>
 
-      {/* Air Condition - button */}
+      {/* Air Condition - full width button */}
       <button
+        onClick={() => setShowAirConditionModal(true)}
         disabled={isDisabled}
-        className="flex items-center gap-2 hover:opacity-80 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
+        className="flex items-center justify-center gap-2 w-full py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
       >
-        <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
-          <img src="/icons/ventilation.svg" alt="" className="w-5 icon-theme" />
-        </div>
-        <span className="text-sm text-bambu-gray">Air Condition</span>
+        <img src="/icons/ventilation.svg" alt="" className="w-5 h-5 icon-theme" />
+        <span className="text-xs text-bambu-gray">Air Condition</span>
       </button>
 
-      {/* Lamp - button (toggle, state not tracked in status yet) */}
-      <button
-        onClick={() => lightMutation.mutate(true)}
-        disabled={isDisabled || lightMutation.isPending}
-        className="flex items-center gap-2 hover:opacity-80 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
-      >
-        <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
-          <img src="/icons/lamp.svg" alt="" className="w-4 icon-theme" />
-        </div>
-        <span className="text-sm text-bambu-gray">Lamp</span>
-      </button>
+      {/* Speed & Lamp - half width each */}
+      <div className="flex items-center gap-2">
+        {/* Speed */}
+        <button
+          onClick={() => setShowSpeedModal(true)}
+          disabled={isDisabled}
+          className="flex-1 flex flex-col items-center py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          <img src="/icons/speed.svg" alt="" className="w-5 h-5 icon-theme" />
+          <span className="text-[10px] text-bambu-gray mt-0.5">
+            {status?.speed_level === 1 ? '50%' : status?.speed_level === 3 ? '124%' : status?.speed_level === 4 ? '166%' : '100%'}
+          </span>
+        </button>
+
+        {/* Lamp */}
+        <button
+          onClick={() => lightMutation.mutate(!(status?.chamber_light ?? false))}
+          disabled={isDisabled || lightMutation.isPending}
+          className="flex-1 flex flex-col items-center py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          <img src="/icons/lamp.svg" alt="" className={`w-5 h-5 ${status?.chamber_light ? 'icon-green' : 'icon-theme'}`} />
+          <span className="text-[10px] text-bambu-gray mt-0.5">Lamp</span>
+        </button>
+      </div>
     </div>
+
+    {/* Air Condition Modal */}
+    {showAirConditionModal && (
+      <AirConditionModal
+        printer={printer}
+        status={status}
+        onClose={() => setShowAirConditionModal(false)}
+      />
+    )}
+
+    {/* Speed Modal */}
+    {showSpeedModal && (
+      <SpeedModal
+        printer={printer}
+        status={status}
+        onClose={() => setShowSpeedModal(false)}
+      />
+    )}
+    </>
   );
 }

+ 4 - 5
frontend/src/pages/ControlPage.tsx

@@ -220,11 +220,10 @@ export function ControlPage() {
                 <div className="flex gap-4 bg-bambu-dark-secondary rounded-[8px] p-4 overflow-hidden" style={{ minHeight: '300px' }}>
                   {/* Temperature Column */}
                   <TemperatureColumn
-                  printerId={selectedPrinter.id}
-                  status={selectedStatus}
-                  nozzleCount={selectedPrinter.nozzle_count}
-                  disabled={isCalibrating}
-                />
+                    printer={selectedPrinter}
+                    status={selectedStatus}
+                    disabled={isCalibrating}
+                  />
 
                 {/* Movement Column */}
                 <div className="flex-1 flex gap-6 items-center justify-center">

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-CH7yvvuS.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-DyjgQh1m.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-DzdPY90c.js


+ 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-1LVQBXOF.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CH7yvvuS.css">
+    <script type="module" crossorigin src="/assets/index-DzdPY90c.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DyjgQh1m.css">
   </head>
   <body>
     <div id="root"></div>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác