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
This commit is contained in:
maziggy
2026-01-02 16:05:20 +01:00
parent 83cbac04b7
commit 94c961dbaf
14 changed files with 608 additions and 484 deletions
+11 -1
View File
@@ -64,9 +64,19 @@ All notable changes to Bambuddy will be documented in this file.
- Progress bar tracks items toward target count
- Useful for batch printing (e.g., 10 copies in one print = 10 items)
- 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
- **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
- **Spoolman info banner** - Updated settings UI with clearer sync documentation
+1
View File
@@ -55,6 +55,7 @@
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
- Live camera streaming (MJPEG) & snapshots
- Fan status monitoring (part cooling, auxiliary, chamber)
- Printer control (stop, pause, resume)
- Skip objects during print
- AMS slot RFID re-read
+2 -1
View File
@@ -237,7 +237,8 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
status_key = (
f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
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:
return # No change, skip broadcast
+5
View File
@@ -153,3 +153,8 @@ class PrinterStatus(BaseModel):
last_ams_update: float = 0.0
# Number of printable objects in current print (for skip objects feature)
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
View File
@@ -150,6 +150,11 @@ class PrinterState:
printable_objects: dict = field(default_factory=dict)
# Objects that have been skipped during the current print
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
@@ -912,6 +917,40 @@ class BambuMQTTClient:
if "total_layer_num" in data:
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
if "stg_cur" in data:
new_stg = data["stg_cur"]
+5
View File
@@ -477,6 +477,11 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
"stg_cur_name": get_derived_status_name(state),
# Printable objects count for skip objects feature
"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
# Include PAUSE/PAUSED states so skip objects modal can show cover
Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

+5
View File
@@ -165,6 +165,11 @@ export interface PrinterStatus {
last_ams_update: number;
// Number of printable objects in current print (for skip objects feature)
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 {
+139 -81
View File
@@ -34,6 +34,9 @@ import {
Play,
X,
Monitor,
Fan,
Wind,
AirVent,
} from 'lucide-react';
// Custom Skip Objects icon - arrow jumping over boxes
@@ -1581,101 +1584,156 @@ function PrinterCard({
</>
)}
{/* Temperatures + Print Controls */}
{/* Temperatures */}
{status.temperatures && viewMode === 'expanded' && (() => {
// Use actual heater states from MQTT stream
const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
const bedHeating = status.temperatures.bed_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
const isRunning = status.state === 'RUNNING';
const isPaused = status.state === 'PAUSED' || status.state === 'PAUSE';
const isPrinting = isRunning || isPaused;
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 (
<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>
{/* 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>
<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>
{/* 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>
{/* 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>
);
@@ -1689,9 +1747,9 @@ function PrinterCard({
const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
return (
<div className="mt-4 pt-3 border-t border-bambu-dark-tertiary/50">
<div className="mt-3">
{/* 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">
Filaments
</span>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<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>
<body>
<div id="root"></div>