Browse Source

Add real-time fan status monitoring to printer cards

- Controls section: New section before Filaments with fan status and printer controls
  - Part cooling fan (Fan icon, cyan)
  - Auxiliary fan (Wind icon, blue)
  - Chamber fan (AirVent icon, green)
  - Stop/Pause/Resume buttons moved from Temperature section
- Real-time updates: Fan speeds now broadcast via WebSocket
- Backend: Parse MQTT fan data (cooling_fan_speed, big_fan1_speed, big_fan2_speed)
  - Convert 0-15 scale to 0-100%
- Always-visible badges with dynamic coloring based on fan state
- Removed horizontal dividers before Controls and Filaments sections
maziggy 4 months ago
parent
commit
94c961dbaf

+ 11 - 1
CHANGELOG.md

@@ -64,9 +64,19 @@ All notable changes to Bambuddy will be documented in this file.
   - Progress bar tracks items toward target count
   - Progress bar tracks items toward target count
   - Useful for batch printing (e.g., 10 copies in one print = 10 items)
   - Useful for batch printing (e.g., 10 copies in one print = 10 items)
   - Default quantity of 1 for backwards compatibility
   - Default quantity of 1 for backwards compatibility
+- **Fan status display** - Real-time fan speeds in new Controls section:
+  - Part cooling fan, Auxiliary fan, Chamber fan status
+  - Distinct icons for each fan type (Fan, Wind, AirVent)
+  - Dynamic coloring: active fans show colored, off fans show gray
+  - Percentage display (0-100%)
+  - Real-time updates via WebSocket
+  - Always visible on printer cards in expanded view
 
 
 ### Changed
 ### Changed
-- **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons
+- **Printer card layout** - Reorganized expanded view with new Controls section:
+  - Temperature display is now standalone (no longer shares row with buttons)
+  - New Controls section contains fan status (left) and print buttons (right)
+  - Removed divider lines before Controls and Filaments sections for cleaner look
 - **Cover image availability** - Print cover image now shown in PAUSE/PAUSED states (not just RUNNING) for skip objects modal
 - **Cover image availability** - Print cover image now shown in PAUSE/PAUSED states (not just RUNNING) for skip objects modal
 - **Spoolman info banner** - Updated settings UI with clearer sync documentation
 - **Spoolman info banner** - Updated settings UI with clearer sync documentation
 
 

+ 1 - 0
README.md

@@ -55,6 +55,7 @@
 ### 📊 Monitoring & Control
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
 - Live camera streaming (MJPEG) & snapshots
+- Fan status monitoring (part cooling, auxiliary, chamber)
 - Printer control (stop, pause, resume)
 - Printer control (stop, pause, resume)
 - Skip objects during print
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read

+ 2 - 1
backend/app/main.py

@@ -237,7 +237,8 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     status_key = (
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
-        f"{state.stg_cur}:{bed_target}:{nozzle_target}"
+        f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
+        f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}"
     )
     )
     if _last_status_broadcast.get(printer_id) == status_key:
     if _last_status_broadcast.get(printer_id) == status_key:
         return  # No change, skip broadcast
         return  # No change, skip broadcast

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

@@ -153,3 +153,8 @@ class PrinterStatus(BaseModel):
     last_ams_update: float = 0.0
     last_ams_update: float = 0.0
     # Number of printable objects in current print (for skip objects feature)
     # Number of printable objects in current print (for skip objects feature)
     printable_objects_count: int = 0
     printable_objects_count: int = 0
+    # Fan speeds (0-100 percentage, None if not available for this model)
+    cooling_fan_speed: int | None = None  # Part cooling fan
+    big_fan1_speed: int | None = None  # Auxiliary fan
+    big_fan2_speed: int | None = None  # Chamber/exhaust fan
+    heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan

+ 39 - 0
backend/app/services/bambu_mqtt.py

@@ -150,6 +150,11 @@ class PrinterState:
     printable_objects: dict = field(default_factory=dict)
     printable_objects: dict = field(default_factory=dict)
     # Objects that have been skipped during the current print
     # Objects that have been skipped during the current print
     skipped_objects: list = field(default_factory=list)
     skipped_objects: list = field(default_factory=list)
+    # Fan speeds (0-100 percentage, None if not available for this model)
+    cooling_fan_speed: int | None = None  # Part cooling fan
+    big_fan1_speed: int | None = None  # Auxiliary fan
+    big_fan2_speed: int | None = None  # Chamber/exhaust fan
+    heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
 
 
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -912,6 +917,40 @@ class BambuMQTTClient:
         if "total_layer_num" in data:
         if "total_layer_num" in data:
             self.state.total_layers = int(data["total_layer_num"])
             self.state.total_layers = int(data["total_layer_num"])
 
 
+        # Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
+        # Convert to 0-100 percentage for display
+        def parse_fan_speed(value: str | int | None) -> int | None:
+            if value is None:
+                return None
+            try:
+                speed = int(value)
+                # MQTT reports 0-15 speed levels, convert to percentage (0-100)
+                # 15 = 100%, so multiply by 100/15 ≈ 6.67
+                if speed <= 15:
+                    return round(speed * 100 / 15)
+                # If already a percentage (0-255 scale from some printers), convert
+                elif speed <= 255:
+                    return round(speed * 100 / 255)
+                return speed
+            except (ValueError, TypeError):
+                return None
+
+        # Log fan fields once for debugging
+        if not hasattr(self, "_fan_fields_logged"):
+            fan_fields = {k: v for k, v in data.items() if "fan" in k.lower()}
+            if fan_fields:
+                logger.info(f"[{self.serial_number}] Fan fields in MQTT data: {fan_fields}")
+                self._fan_fields_logged = True
+
+        if "cooling_fan_speed" in data:
+            self.state.cooling_fan_speed = parse_fan_speed(data["cooling_fan_speed"])
+        if "big_fan1_speed" in data:
+            self.state.big_fan1_speed = parse_fan_speed(data["big_fan1_speed"])
+        if "big_fan2_speed" in data:
+            self.state.big_fan2_speed = parse_fan_speed(data["big_fan2_speed"])
+        if "heatbreak_fan_speed" in data:
+            self.state.heatbreak_fan_speed = parse_fan_speed(data["heatbreak_fan_speed"])
+
         # Calibration stage tracking
         # Calibration stage tracking
         if "stg_cur" in data:
         if "stg_cur" in data:
             new_stg = data["stg_cur"]
             new_stg = data["stg_cur"]

+ 5 - 0
backend/app/services/printer_manager.py

@@ -477,6 +477,11 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         "stg_cur_name": get_derived_status_name(state),
         "stg_cur_name": get_derived_status_name(state),
         # Printable objects count for skip objects feature
         # Printable objects count for skip objects feature
         "printable_objects_count": len(state.printable_objects),
         "printable_objects_count": len(state.printable_objects),
+        # Fan speeds (0-100 percentage, None if not available)
+        "cooling_fan_speed": state.cooling_fan_speed,
+        "big_fan1_speed": state.big_fan1_speed,
+        "big_fan2_speed": state.big_fan2_speed,
+        "heatbreak_fan_speed": state.heatbreak_fan_speed,
     }
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE/PAUSED states so skip objects modal can show cover
     # Include PAUSE/PAUSED states so skip objects modal can show cover

BIN
frontend/public/img/bambuddy_logo_dark_transparent.png


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

@@ -165,6 +165,11 @@ export interface PrinterStatus {
   last_ams_update: number;
   last_ams_update: number;
   // Number of printable objects in current print (for skip objects feature)
   // Number of printable objects in current print (for skip objects feature)
   printable_objects_count: number;
   printable_objects_count: number;
+  // Fan speeds (0-100 percentage, null if not available for this model)
+  cooling_fan_speed: number | null;  // Part cooling fan
+  big_fan1_speed: number | null;     // Auxiliary fan
+  big_fan2_speed: number | null;     // Chamber/exhaust fan
+  heatbreak_fan_speed: number | null; // Hotend heatbreak fan
 }
 }
 
 
 export interface PrinterCreate {
 export interface PrinterCreate {

+ 140 - 82
frontend/src/pages/PrintersPage.tsx

@@ -34,6 +34,9 @@ import {
   Play,
   Play,
   X,
   X,
   Monitor,
   Monitor,
+  Fan,
+  Wind,
+  AirVent,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 // Custom Skip Objects icon - arrow jumping over boxes
 // Custom Skip Objects icon - arrow jumping over boxes
@@ -1581,101 +1584,156 @@ function PrinterCard({
               </>
               </>
             )}
             )}
 
 
-            {/* Temperatures + Print Controls */}
+            {/* Temperatures */}
             {status.temperatures && viewMode === 'expanded' && (() => {
             {status.temperatures && viewMode === 'expanded' && (() => {
               // Use actual heater states from MQTT stream
               // Use actual heater states from MQTT stream
               const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
               const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
               const bedHeating = status.temperatures.bed_heating || false;
               const bedHeating = status.temperatures.bed_heating || false;
               const chamberHeating = status.temperatures.chamber_heating || false;
               const chamberHeating = status.temperatures.chamber_heating || false;
 
 
+              return (
+                <div className="grid grid-cols-3 gap-2">
+                  {/* Nozzle temp - combined for dual nozzle */}
+                  <div className="text-center p-2 bg-bambu-dark rounded-lg">
+                    <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-orange-400" isHeating={nozzleHeating} />
+                    {status.temperatures.nozzle_2 !== undefined ? (
+                      <>
+                        <p className="text-[10px] text-bambu-gray">L / R</p>
+                        <p className="text-xs text-white">
+                          {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°
+                        </p>
+                      </>
+                    ) : (
+                      <>
+                        <p className="text-[10px] text-bambu-gray">Nozzle</p>
+                        <p className="text-xs text-white">
+                          {Math.round(status.temperatures.nozzle || 0)}°C
+                        </p>
+                      </>
+                    )}
+                  </div>
+                  <div className="text-center p-2 bg-bambu-dark rounded-lg">
+                    <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-blue-400" isHeating={bedHeating} />
+                    <p className="text-[10px] text-bambu-gray">Bed</p>
+                    <p className="text-xs text-white">
+                      {Math.round(status.temperatures.bed || 0)}°C
+                    </p>
+                  </div>
+                  {status.temperatures.chamber !== undefined ? (
+                    <div className="text-center p-2 bg-bambu-dark rounded-lg">
+                      <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-green-400" isHeating={chamberHeating} />
+                      <p className="text-[10px] text-bambu-gray">Chamber</p>
+                      <p className="text-xs text-white">
+                        {Math.round(status.temperatures.chamber || 0)}°C
+                      </p>
+                    </div>
+                  ) : (
+                    <div /> /* Empty placeholder to maintain grid */
+                  )}
+                </div>
+              );
+            })()}
+
+            {/* Controls - Fans + Print Buttons */}
+            {viewMode === 'expanded' && (() => {
               // Determine print state for control buttons
               // Determine print state for control buttons
               const isRunning = status.state === 'RUNNING';
               const isRunning = status.state === 'RUNNING';
               const isPaused = status.state === 'PAUSED' || status.state === 'PAUSE';
               const isPaused = status.state === 'PAUSED' || status.state === 'PAUSE';
               const isPrinting = isRunning || isPaused;
               const isPrinting = isRunning || isPaused;
               const isControlBusy = stopPrintMutation.isPending || pausePrintMutation.isPending || resumePrintMutation.isPending;
               const isControlBusy = stopPrintMutation.isPending || pausePrintMutation.isPending || resumePrintMutation.isPending;
 
 
+              // Fan data
+              const partFan = status.cooling_fan_speed;
+              const auxFan = status.big_fan1_speed;
+              const chamberFan = status.big_fan2_speed;
+
               return (
               return (
-                <div className="flex gap-3">
-                  {/* Temperature cards */}
-                  <div className="flex-1 grid grid-cols-3 gap-2">
-                    {/* Nozzle temp - combined for dual nozzle */}
-                    <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                      <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-orange-400" isHeating={nozzleHeating} />
-                      {status.temperatures.nozzle_2 !== undefined ? (
-                        <>
-                          <p className="text-[10px] text-bambu-gray">L / R</p>
-                          <p className="text-xs text-white">
-                            {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°
-                          </p>
-                        </>
-                      ) : (
-                        <>
-                          <p className="text-[10px] text-bambu-gray">Nozzle</p>
-                          <p className="text-xs text-white">
-                            {Math.round(status.temperatures.nozzle || 0)}°C
-                          </p>
-                        </>
-                      )}
-                    </div>
-                    <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                      <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-blue-400" isHeating={bedHeating} />
-                      <p className="text-[10px] text-bambu-gray">Bed</p>
-                      <p className="text-xs text-white">
-                        {Math.round(status.temperatures.bed || 0)}°C
-                      </p>
-                    </div>
-                    {status.temperatures.chamber !== undefined ? (
-                      <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                        <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-green-400" isHeating={chamberHeating} />
-                        <p className="text-[10px] text-bambu-gray">Chamber</p>
-                        <p className="text-xs text-white">
-                          {Math.round(status.temperatures.chamber || 0)}°C
-                        </p>
-                      </div>
-                    ) : (
-                      <div /> /* Empty placeholder to maintain grid */
-                    )}
+                <div className="mt-3">
+                  {/* Section Header */}
+                  <div className="flex items-center gap-2 mb-2">
+                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                      Controls
+                    </span>
+                    <div className="flex-1 h-px bg-bambu-dark-tertiary/30" />
                   </div>
                   </div>
 
 
-                  {/* Print control buttons */}
-                  <div className="flex flex-col justify-center gap-1.5 w-20">
-                    {/* Stop button - visible when printing/paused */}
-                    <button
-                      onClick={() => setShowStopConfirm(true)}
-                      disabled={!isPrinting || isControlBusy}
-                      className={`
-                        flex items-center justify-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium
-                        transition-colors
-                        ${isPrinting
-                          ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
-                          : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
-                        }
-                      `}
-                      title="Stop print"
-                    >
-                      <Square className="w-3 h-3" />
-                      Stop
-                    </button>
-
-                    {/* Pause/Resume button */}
-                    <button
-                      onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}
-                      disabled={!isPrinting || isControlBusy}
-                      className={`
-                        flex items-center justify-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium
-                        transition-colors
-                        ${isPrinting
-                          ? isPaused
-                            ? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
-                            : 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
-                          : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
-                        }
-                      `}
-                      title={isPaused ? 'Resume print' : 'Pause print'}
-                    >
-                      {isPaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
-                      {isPaused ? 'Resume' : 'Pause'}
-                    </button>
+                  <div className="flex items-center justify-between gap-2">
+                    {/* Left: Fan Status - always visible, dynamic coloring */}
+                    <div className="flex items-center gap-2">
+                      {/* Part Cooling Fan */}
+                      <div
+                        className={`flex items-center gap-1 px-1.5 py-1 rounded ${partFan && partFan > 0 ? 'bg-cyan-500/10' : 'bg-bambu-dark'}`}
+                        title="Part Cooling Fan"
+                      >
+                        <Fan className={`w-3.5 h-3.5 ${partFan && partFan > 0 ? 'text-cyan-400' : 'text-bambu-gray/50'}`} />
+                        <span className={`text-[10px] ${partFan && partFan > 0 ? 'text-cyan-400' : 'text-bambu-gray/50'}`}>
+                          {partFan ?? 0}%
+                        </span>
+                      </div>
+
+                      {/* Auxiliary Fan */}
+                      <div
+                        className={`flex items-center gap-1 px-1.5 py-1 rounded ${auxFan && auxFan > 0 ? 'bg-blue-500/10' : 'bg-bambu-dark'}`}
+                        title="Auxiliary Fan"
+                      >
+                        <Wind className={`w-3.5 h-3.5 ${auxFan && auxFan > 0 ? 'text-blue-400' : 'text-bambu-gray/50'}`} />
+                        <span className={`text-[10px] ${auxFan && auxFan > 0 ? 'text-blue-400' : 'text-bambu-gray/50'}`}>
+                          {auxFan ?? 0}%
+                        </span>
+                      </div>
+
+                      {/* Chamber Fan */}
+                      <div
+                        className={`flex items-center gap-1 px-1.5 py-1 rounded ${chamberFan && chamberFan > 0 ? 'bg-green-500/10' : 'bg-bambu-dark'}`}
+                        title="Chamber Fan"
+                      >
+                        <AirVent className={`w-3.5 h-3.5 ${chamberFan && chamberFan > 0 ? 'text-green-400' : 'text-bambu-gray/50'}`} />
+                        <span className={`text-[10px] ${chamberFan && chamberFan > 0 ? 'text-green-400' : 'text-bambu-gray/50'}`}>
+                          {chamberFan ?? 0}%
+                        </span>
+                      </div>
+                    </div>
+
+                    {/* Right: Print Control Buttons */}
+                    <div className="flex items-center gap-2">
+                      {/* Stop button */}
+                      <button
+                        onClick={() => setShowStopConfirm(true)}
+                        disabled={!isPrinting || isControlBusy}
+                        className={`
+                          flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
+                          transition-colors
+                          ${isPrinting
+                            ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
+                            : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                          }
+                        `}
+                        title="Stop print"
+                      >
+                        <Square className="w-3 h-3" />
+                        Stop
+                      </button>
+
+                      {/* Pause/Resume button */}
+                      <button
+                        onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}
+                        disabled={!isPrinting || isControlBusy}
+                        className={`
+                          flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
+                          transition-colors
+                          ${isPrinting
+                            ? isPaused
+                              ? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
+                              : 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
+                            : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                          }
+                        `}
+                        title={isPaused ? 'Resume print' : 'Pause print'}
+                      >
+                        {isPaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
+                        {isPaused ? 'Resume' : 'Pause'}
+                      </button>
+                    </div>
                   </div>
                   </div>
                 </div>
                 </div>
               );
               );
@@ -1689,9 +1747,9 @@ function PrinterCard({
               const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
               const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
 
 
               return (
               return (
-                <div className="mt-4 pt-3 border-t border-bambu-dark-tertiary/50">
+                <div className="mt-3">
                   {/* Section Header */}
                   {/* Section Header */}
-                  <div className="flex items-center gap-2 mb-3">
+                  <div className="flex items-center gap-2 mb-2">
                     <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
                     <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
                       Filaments
                       Filaments
                     </span>
                     </span>

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


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


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


BIN
static/img/bambuddy_logo_dark_transparent.png


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D9x3e2g2.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Bs58vo0R.css">
+    <script type="module" crossorigin src="/assets/index-DvVnGkck.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-j3n1gAEX.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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