Wired air condition, speed and light buttons

This commit is contained in:
Martin Ziegler
2025-12-03 16:44:55 +01:00
parent 0ab0707a87
commit 9e638e2d0f
13 changed files with 1221 additions and 431 deletions
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
@@ -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>
);
}
@@ -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>
);
}
@@ -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
View File
@@ -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">
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
+2 -2
View File
@@ -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>