mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Add remote AMS drying controls (#292)
Start, monitor, and stop drying sessions for AMS 2 Pro (n3f) and AMS-HT (n3s) directly from the Printers page. Flame icon in AMS card header opens a popover with filament-based temperature/duration presets from BambuStudio. Live countdown status bar shows time remaining. Backend: cache module_type from MQTT get_version, expose dry_time and module_type in both WebSocket and REST paths, add supports_drying() with firmware gating, add server-side guard on start_drying endpoint. Frontend: drying button, popover with filament select + temp/duration sliders, status bar, start/stop mutations. i18n for all 7 locales. Supported: X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, X1E. Not supported: P2S, A1, A1 Mini, H2S, H2C.
This commit is contained in:
+2
-1
@@ -2,9 +2,10 @@
|
||||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.2.2b3] - 2026-03-11
|
||||
## [0.2.2b3] - 2026-03-12
|
||||
|
||||
### New Features
|
||||
- **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. A flame icon appears on supported AMS cards; clicking it opens a popover to select filament type (PLA, PETG, TPU, ABS, ASA, PA, PC, PVA) with official BambuStudio temperature/duration presets, or set temperature manually. When drying is active, a status bar shows the time remaining with a live countdown and stop button. Supported on X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, and X1E. Not supported on P2S, A1, A1 Mini, H2S, or H2C. Requires `printers:control` permission when authentication is enabled.
|
||||
- **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
|
||||
- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.
|
||||
- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
|
||||
|
||||
@@ -91,6 +91,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
|
||||
- AMS slot RFID re-read
|
||||
- AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
|
||||
- AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
|
||||
- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets
|
||||
- Dual external spool support for H2D (Ext-L / Ext-R)
|
||||
- HMS error monitoring with history and clear errors
|
||||
- Print success rates & trends
|
||||
|
||||
@@ -35,7 +35,12 @@ from backend.app.services.bambu_ftp import (
|
||||
get_storage_info_async,
|
||||
list_files_async,
|
||||
)
|
||||
from backend.app.services.printer_manager import get_derived_status_name, printer_manager, supports_chamber_temp
|
||||
from backend.app.services.printer_manager import (
|
||||
get_derived_status_name,
|
||||
printer_manager,
|
||||
supports_chamber_temp,
|
||||
supports_drying,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/printers", tags=["printers"])
|
||||
@@ -411,6 +416,8 @@ async def get_printer_status(
|
||||
tray_uuid=tray_uuid,
|
||||
nozzle_temp_min=tray_data.get("nozzle_temp_min"),
|
||||
nozzle_temp_max=tray_data.get("nozzle_temp_max"),
|
||||
drying_temp=tray_data.get("drying_temp"),
|
||||
drying_time=tray_data.get("drying_time"),
|
||||
)
|
||||
)
|
||||
# Prefer humidity_raw (percentage) over humidity (index 1-5)
|
||||
@@ -443,6 +450,9 @@ async def get_printer_status(
|
||||
serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
|
||||
# Firmware version: populated by _handle_version_info from info.module ams/* entries
|
||||
sw_ver=str(ams_data.get("sw_ver") or ""),
|
||||
# Drying: dry_time > 0 means drying is active (minutes remaining)
|
||||
dry_time=int(ams_data.get("dry_time") or 0),
|
||||
module_type=str(ams_data.get("module_type") or ""),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -597,6 +607,7 @@ async def get_printer_status(
|
||||
firmware_version=state.firmware_version,
|
||||
developer_mode=state.developer_mode if state else None,
|
||||
plate_cleared=printer_manager.is_plate_cleared(printer_id),
|
||||
supports_drying=supports_drying(printer.model, state.firmware_version),
|
||||
)
|
||||
|
||||
|
||||
@@ -1447,6 +1458,63 @@ async def clear_mqtt_logs(
|
||||
return {"status": "cleared"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# AMS Drying Endpoints
|
||||
# ============================================
|
||||
|
||||
|
||||
@router.post("/{printer_id}/drying/start")
|
||||
async def start_drying(
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
temp: int = 45,
|
||||
duration: int = 4,
|
||||
filament: str = "",
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send AMS drying start command. temp=45-85, duration=hours."""
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(404, "Printer not found")
|
||||
|
||||
# Server-side guard: reject if this model/firmware doesn't support drying
|
||||
live_state = printer_manager.get_status(printer_id)
|
||||
firmware = live_state.firmware_version if live_state else None
|
||||
if not supports_drying(printer.model, firmware):
|
||||
raise HTTPException(400, "Drying not supported for this printer model or firmware version")
|
||||
|
||||
if temp < 45 or temp > 85:
|
||||
raise HTTPException(400, "Temperature must be 45-85°C")
|
||||
if duration < 1 or duration > 24:
|
||||
raise HTTPException(400, "Duration must be 1-24 hours")
|
||||
|
||||
success = printer_manager.send_drying_command(printer_id, ams_id, temp, duration, mode=1, filament=filament)
|
||||
if not success:
|
||||
raise HTTPException(400, "Printer not connected")
|
||||
return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}
|
||||
|
||||
|
||||
@router.post("/{printer_id}/drying/stop")
|
||||
async def stop_drying(
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send AMS drying stop command."""
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(404, "Printer not found")
|
||||
|
||||
success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
|
||||
if not success:
|
||||
raise HTTPException(400, "Printer not connected")
|
||||
return {"status": "drying_stopped", "ams_id": ams_id}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Print Options (AI Detection) Endpoints
|
||||
# ============================================
|
||||
|
||||
+4
-1
@@ -359,12 +359,15 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
|
||||
|
||||
# Include tray_now and vt_tray hash so external spool changes trigger broadcasts
|
||||
vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
|
||||
# Include AMS dry_time values so drying status changes trigger broadcasts
|
||||
ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
|
||||
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.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
|
||||
f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}"
|
||||
f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
|
||||
f"{ams_dry_key}"
|
||||
)
|
||||
|
||||
# MQTT relay - publish status (before dedup check - always publish to MQTT)
|
||||
|
||||
@@ -130,6 +130,8 @@ class AMSTray(BaseModel):
|
||||
tray_uuid: str | None = None # Bambu Lab spool UUID (32-char hex)
|
||||
nozzle_temp_min: int | None = None # Min nozzle temperature
|
||||
nozzle_temp_max: int | None = None # Max nozzle temperature
|
||||
drying_temp: int | None = None # RFID-recommended drying temp
|
||||
drying_time: int | None = None # RFID-recommended drying time (hours)
|
||||
|
||||
|
||||
class AMSUnit(BaseModel):
|
||||
@@ -140,6 +142,8 @@ class AMSUnit(BaseModel):
|
||||
tray: list[AMSTray] = []
|
||||
serial_number: str = "" # AMS unit serial number (sn from MQTT)
|
||||
sw_ver: str = "" # AMS firmware version (from get_version info.module)
|
||||
dry_time: int = 0 # Minutes remaining (0 = not drying, >0 = drying active)
|
||||
module_type: str = "" # "ams", "n3f", "n3s"
|
||||
|
||||
|
||||
class NozzleInfoResponse(BaseModel):
|
||||
@@ -257,3 +261,5 @@ class PrinterStatus(BaseModel):
|
||||
developer_mode: bool | None = None
|
||||
# Queue: user has acknowledged plate is cleared for next queued print
|
||||
plate_cleared: bool = False
|
||||
# AMS drying support
|
||||
supports_drying: bool = False
|
||||
|
||||
@@ -745,9 +745,12 @@ class BambuMQTTClient:
|
||||
sw_ver = module.get("sw_ver", "")
|
||||
sn = module.get("sn", "")
|
||||
|
||||
# Extract module type from prefix (e.g. "ams/0" → "ams", "n3f/0" → "n3f")
|
||||
module_type = name.split("/", 1)[0]
|
||||
|
||||
# Always cache so _apply_ams_version_cache can apply it when AMS data arrives
|
||||
if sw_ver or sn:
|
||||
self._ams_version_cache[ams_id] = {"sw_ver": sw_ver, "sn": sn}
|
||||
if sw_ver or sn or module_type:
|
||||
self._ams_version_cache[ams_id] = {"sw_ver": sw_ver, "sn": sn, "module_type": module_type}
|
||||
state_changed = True
|
||||
|
||||
# Also directly update any AMS unit already present in raw_data
|
||||
@@ -766,6 +769,8 @@ class BambuMQTTClient:
|
||||
# Only set sn from version info if not already present in AMS data
|
||||
if sn and not ams_unit.get("sn"):
|
||||
ams_unit["sn"] = sn
|
||||
if module_type:
|
||||
ams_unit["module_type"] = module_type
|
||||
break
|
||||
|
||||
# Trigger state change callback AFTER both loops so AMS sn/sw_ver are
|
||||
@@ -832,6 +837,9 @@ class BambuMQTTClient:
|
||||
# Only set sn if not already present in AMS data
|
||||
if sn and not unit.get("sn") and not unit.get("serial_number"):
|
||||
unit["sn"] = sn
|
||||
module_type = cached.get("module_type") or ""
|
||||
if module_type and not unit.get("module_type"):
|
||||
unit["module_type"] = module_type
|
||||
|
||||
def _parse_xcam_data(self, xcam_data):
|
||||
"""Parse xcam data for camera settings and AI detection options."""
|
||||
@@ -2898,6 +2906,45 @@ class BambuMQTTClient:
|
||||
"""Check if logging is enabled."""
|
||||
return self._logging_enabled
|
||||
|
||||
def send_drying_command(self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = ""):
|
||||
"""Send AMS drying start/stop command.
|
||||
|
||||
Args:
|
||||
ams_id: AMS unit ID (0-3 for AMS 2 Pro, 128-135 for AMS-HT)
|
||||
temp: Target drying temperature (45-65 for AMS 2 Pro, 45-85 for AMS-HT)
|
||||
duration: Drying duration in hours
|
||||
mode: 1=start, 0=stop
|
||||
filament: Filament type string (e.g. "PLA", "PETG")
|
||||
"""
|
||||
if not self._client:
|
||||
return False
|
||||
self._sequence_id += 1
|
||||
command = {
|
||||
"print": {
|
||||
"sequence_id": str(self._sequence_id),
|
||||
"command": "ams_filament_drying",
|
||||
"ams_id": ams_id,
|
||||
"temp": temp,
|
||||
"cooling_temp": 20 if mode == 1 else 0,
|
||||
"duration": duration,
|
||||
"humidity": 0,
|
||||
"mode": mode,
|
||||
"rotate_tray": False,
|
||||
"filament": filament,
|
||||
"close_power_conflict": False,
|
||||
}
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
||||
logger.info(
|
||||
"[%s] Sent drying command: ams_id=%d, temp=%d, duration=%d, mode=%d",
|
||||
self.serial_number,
|
||||
ams_id,
|
||||
temp,
|
||||
duration,
|
||||
mode,
|
||||
)
|
||||
return True
|
||||
|
||||
def _handle_kprofile_response(self, data: dict):
|
||||
"""Handle K-profile response from printer."""
|
||||
response_nozzle = data.get("nozzle_diameter")
|
||||
|
||||
@@ -451,7 +451,9 @@ class PrintScheduler:
|
||||
return None, f"No matching material/color. Waiting on {', '.join(all_missing)}"
|
||||
# else: fall through — printers_busy will be appended below
|
||||
else:
|
||||
names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament]
|
||||
names_and_missing = [
|
||||
f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament
|
||||
]
|
||||
reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
|
||||
if printers_busy:
|
||||
reasons.append(f"Busy: {', '.join(printers_busy)}")
|
||||
|
||||
@@ -82,6 +82,41 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
|
||||
return model_upper in A1_MODELS
|
||||
|
||||
|
||||
# Minimum firmware versions for AMS drying support (confirmed via capture testing)
|
||||
# Keys are exact model names (upper-cased). Do NOT use substring matching — it would
|
||||
# incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
|
||||
_DRYING_MIN_FIRMWARE: dict[str, str] = {
|
||||
"H2D": "01.02.30.00",
|
||||
"X1": "01.09.00.00",
|
||||
"X1C": "01.09.00.00",
|
||||
"P1P": "01.08.00.00",
|
||||
"P1S": "01.08.00.00",
|
||||
}
|
||||
# Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
|
||||
_DRYING_UNSUPPORTED_MODELS = frozenset(
|
||||
{"P2S", "A1", "A1MINI", "A1-MINI", "A1 MINI", "H2S", "H2C", "N7", "O1C", "O1C2", "O1S", "N1", "N2S"}
|
||||
)
|
||||
|
||||
|
||||
def supports_drying(model: str | None, firmware: str | None) -> bool:
|
||||
"""Check if a printer model supports AMS drying commands.
|
||||
|
||||
Known models with confirmed min firmware get version-gated.
|
||||
Known unsupported models are blocked.
|
||||
All other models (H2D Pro, X1E, future models) are allowed —
|
||||
the command fails gracefully with result: "fail" if unsupported.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
model_upper = model.strip().upper()
|
||||
if model_upper in _DRYING_UNSUPPORTED_MODELS:
|
||||
return False
|
||||
if model_upper in _DRYING_MIN_FIRMWARE:
|
||||
return bool(firmware and firmware >= _DRYING_MIN_FIRMWARE[model_upper])
|
||||
# For all other models: allow
|
||||
return True
|
||||
|
||||
|
||||
class PrinterInfo:
|
||||
"""Basic printer info for callbacks."""
|
||||
|
||||
@@ -409,6 +444,20 @@ class PrinterManager:
|
||||
return self._clients[printer_id].logging_enabled
|
||||
return False
|
||||
|
||||
def send_drying_command(
|
||||
self,
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
temp: int,
|
||||
duration: int,
|
||||
mode: int = 1,
|
||||
filament: str = "",
|
||||
) -> bool:
|
||||
"""Send AMS drying command to printer."""
|
||||
if printer_id not in self._clients:
|
||||
return False
|
||||
return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament)
|
||||
|
||||
def request_status_update(self, printer_id: int) -> bool:
|
||||
"""Request a full status update from the printer.
|
||||
|
||||
@@ -560,6 +609,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
|
||||
"tray_uuid": tray_uuid,
|
||||
"nozzle_temp_min": tray.get("nozzle_temp_min"),
|
||||
"nozzle_temp_max": tray.get("nozzle_temp_max"),
|
||||
"drying_temp": tray.get("drying_temp"),
|
||||
"drying_time": tray.get("drying_time"),
|
||||
}
|
||||
)
|
||||
# Prefer humidity_raw (actual percentage) over humidity (index 1-5)
|
||||
@@ -593,6 +644,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
|
||||
"serial_number": str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
|
||||
# Firmware version: populated by _handle_version_info from get_version
|
||||
"sw_ver": str(ams_data.get("sw_ver") or ""),
|
||||
# Drying: dry_time > 0 means drying is active (minutes remaining)
|
||||
"dry_time": int(ams_data.get("dry_time") or 0),
|
||||
# Module type: "ams", "n3f", "n3s" (from get_version)
|
||||
"module_type": str(ams_data.get("module_type") or ""),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -699,6 +754,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
|
||||
}
|
||||
for n in (state.nozzle_rack or [])
|
||||
],
|
||||
# AMS drying support
|
||||
"supports_drying": supports_drying(model, state.firmware_version),
|
||||
}
|
||||
# Add cover URL if there's an active print and printer_id is provided
|
||||
# Include PAUSE state so skip objects modal can show cover
|
||||
|
||||
@@ -15,6 +15,7 @@ from backend.app.services.printer_manager import (
|
||||
init_printer_connections,
|
||||
printer_state_to_dict,
|
||||
supports_chamber_temp,
|
||||
supports_drying,
|
||||
)
|
||||
|
||||
|
||||
@@ -673,6 +674,7 @@ class TestPrinterStateToDict:
|
||||
state.wifi_signal = -50
|
||||
state.raw_data = {}
|
||||
state.stg_cur = -1 # No calibration stage active
|
||||
state.firmware_version = None
|
||||
return state
|
||||
|
||||
def test_basic_conversion(self, mock_state):
|
||||
@@ -885,6 +887,79 @@ class TestPrinterStateToDict:
|
||||
# Let's check the actual behavior
|
||||
assert "chamber" not in result["temperatures"]
|
||||
|
||||
def test_ams_drying_fields_included(self, mock_state):
|
||||
"""Verify AMS drying fields (dry_time, module_type) are included in output."""
|
||||
mock_state.raw_data = {
|
||||
"ams": [
|
||||
{
|
||||
"id": 0,
|
||||
"dry_time": 42,
|
||||
"module_type": "n3f",
|
||||
"tray": [
|
||||
{
|
||||
"id": 0,
|
||||
"tray_color": "FF0000",
|
||||
"tray_type": "PLA",
|
||||
"drying_temp": 55,
|
||||
"drying_time": 240,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = printer_state_to_dict(mock_state)
|
||||
|
||||
ams_unit = result["ams"][0]
|
||||
assert ams_unit["dry_time"] == 42
|
||||
assert ams_unit["module_type"] == "n3f"
|
||||
# Tray-level drying fields
|
||||
tray = ams_unit["tray"][0]
|
||||
assert tray["drying_temp"] == 55
|
||||
assert tray["drying_time"] == 240
|
||||
|
||||
|
||||
class TestStatusKeyDryingDedup:
|
||||
"""Regression tests for WebSocket dedup including drying fields.
|
||||
|
||||
The WebSocket broadcast deduplication uses printer_state_to_dict output
|
||||
to detect changes. If drying fields (like dry_time) are missing from
|
||||
the dict, changes to those fields won't trigger broadcasts.
|
||||
"""
|
||||
|
||||
def test_dry_time_change_changes_status_key(self):
|
||||
"""Verify dry_time is present in AMS unit data so dedup can detect changes."""
|
||||
state = MagicMock()
|
||||
state.connected = True
|
||||
state.state = "IDLE"
|
||||
state.current_print = None
|
||||
state.subtask_name = None
|
||||
state.gcode_file = None
|
||||
state.progress = 0
|
||||
state.remaining_time = 0
|
||||
state.layer_num = 0
|
||||
state.total_layers = 0
|
||||
state.temperatures = {"nozzle": 25, "bed": 25}
|
||||
state.hms_errors = []
|
||||
state.ams_status_main = 0
|
||||
state.ams_status_sub = 0
|
||||
state.tray_now = None
|
||||
state.wifi_signal = -50
|
||||
state.stg_cur = -1
|
||||
|
||||
# First state: drying active with 30 minutes remaining
|
||||
state.raw_data = {"ams": [{"id": 0, "dry_time": 30, "module_type": "n3f", "tray": [{"id": 0}]}]}
|
||||
result1 = printer_state_to_dict(state)
|
||||
|
||||
# Second state: drying time decreased
|
||||
state.raw_data = {"ams": [{"id": 0, "dry_time": 29, "module_type": "n3f", "tray": [{"id": 0}]}]}
|
||||
result2 = printer_state_to_dict(state)
|
||||
|
||||
# The dicts should differ — dry_time changed
|
||||
assert result1["ams"][0]["dry_time"] == 30
|
||||
assert result2["ams"][0]["dry_time"] == 29
|
||||
assert result1["ams"] != result2["ams"]
|
||||
|
||||
|
||||
class TestSupportsChamberTemp:
|
||||
"""Tests for supports_chamber_temp helper function."""
|
||||
@@ -955,6 +1030,53 @@ class TestSupportsChamberTemp:
|
||||
assert supports_chamber_temp("N1") is False
|
||||
|
||||
|
||||
class TestSupportsDrying:
|
||||
"""Tests for supports_drying helper function."""
|
||||
|
||||
def test_known_supported_with_firmware(self):
|
||||
"""Verify known models with sufficient firmware return True."""
|
||||
assert supports_drying("X1C", "01.09.00.00") is True
|
||||
assert supports_drying("P1S", "01.08.00.00") is True
|
||||
assert supports_drying("H2D", "01.02.30.00") is True
|
||||
|
||||
def test_known_supported_old_firmware(self):
|
||||
"""Verify known models with old firmware return False."""
|
||||
assert supports_drying("X1C", "01.08.00.00") is False
|
||||
assert supports_drying("P1S", "01.07.00.00") is False
|
||||
|
||||
def test_known_supported_no_firmware(self):
|
||||
"""Verify known models with no firmware return False."""
|
||||
assert supports_drying("X1C", None) is False
|
||||
|
||||
def test_unsupported_models(self):
|
||||
"""Verify models without AMS drying support return False regardless of firmware."""
|
||||
for model in ["P2S", "A1", "A1MINI", "A1-MINI", "H2S", "H2C", "N7", "N1", "N2S"]:
|
||||
assert supports_drying(model, "99.99.99.99") is False, f"Expected False for {model}"
|
||||
|
||||
def test_unknown_models_allowed(self):
|
||||
"""Verify unknown models are allowed (graceful fallback).
|
||||
|
||||
Models not in the unsupported set AND not matching any known firmware-gated
|
||||
model substring get the benefit of the doubt and return True.
|
||||
"H2D Pro" contains "H2D" so it IS firmware-gated (needs firmware).
|
||||
"""
|
||||
# Truly unknown models: no substring match in _DRYING_MIN_FIRMWARE
|
||||
assert supports_drying("FUTURE_MODEL", None) is True
|
||||
# X1E contains "X1" substring, so it IS firmware-gated
|
||||
assert supports_drying("X1E", "01.09.00.00") is True
|
||||
# H2D Pro contains "H2D" substring, so it IS firmware-gated
|
||||
assert supports_drying("H2D Pro", "01.02.30.00") is True
|
||||
|
||||
def test_none_model(self):
|
||||
"""Verify None model returns False."""
|
||||
assert supports_drying(None, "01.09.00.00") is False
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Verify model matching is case-insensitive."""
|
||||
assert supports_drying("x1c", "01.09.00.00") is True
|
||||
assert supports_drying("p2s", "99.99.99.99") is False
|
||||
|
||||
|
||||
class TestGetDerivedStatusName:
|
||||
"""Tests for get_derived_status_name function."""
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Tests for AMS drying feature logic.
|
||||
*
|
||||
* The drying presets, time formatting, module type gating, and temperature
|
||||
* clamping are all defined inline in PrintersPage.tsx. These tests validate
|
||||
* the logic directly by mirroring the relevant constants and functions.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Mirrors the DRYING_PRESETS constant from PrintersPage.tsx.
|
||||
* Format: { n3f temp, n3s temp, n3f hours, n3s hours }
|
||||
*/
|
||||
const DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
|
||||
'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
|
||||
'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
|
||||
'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
|
||||
'ABS': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'ASA': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'PA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
|
||||
'PC': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'PVA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors the inline dry_time formatting from PrintersPage.tsx:
|
||||
* dry_time >= 60 ? `${Math.floor(dry_time / 60)}h ${dry_time % 60}m` : `${dry_time}m`
|
||||
*/
|
||||
function formatDryTime(dry_time: number): string {
|
||||
if (dry_time >= 60) {
|
||||
return `${Math.floor(dry_time / 60)}h ${dry_time % 60}m`;
|
||||
}
|
||||
return `${dry_time}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the temperature clamping from PrintersPage.tsx:
|
||||
* Math.min(maxTemp, Math.max(45, value))
|
||||
* where maxTemp is 65 for n3f and 85 for n3s.
|
||||
*/
|
||||
function clampTemp(value: number, moduleType: 'n3f' | 'n3s'): number {
|
||||
const maxTemp = moduleType === 'n3s' ? 85 : 65;
|
||||
return Math.min(maxTemp, Math.max(45, value));
|
||||
}
|
||||
|
||||
describe('DRYING_PRESETS structure', () => {
|
||||
const expectedFilaments = ['PLA', 'PETG', 'TPU', 'ABS', 'ASA', 'PA', 'PC', 'PVA'];
|
||||
|
||||
it('contains all expected filament types', () => {
|
||||
for (const fil of expectedFilaments) {
|
||||
expect(DRYING_PRESETS).toHaveProperty(fil);
|
||||
}
|
||||
});
|
||||
|
||||
it('has no unexpected filament types', () => {
|
||||
expect(Object.keys(DRYING_PRESETS).sort()).toEqual(expectedFilaments.sort());
|
||||
});
|
||||
|
||||
it('n3f temps are all within 45-65 range', () => {
|
||||
for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {
|
||||
expect(preset.n3f, `${fil} n3f temp`).toBeGreaterThanOrEqual(45);
|
||||
expect(preset.n3f, `${fil} n3f temp`).toBeLessThanOrEqual(65);
|
||||
}
|
||||
});
|
||||
|
||||
it('n3s temps are all within 45-85 range', () => {
|
||||
for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {
|
||||
expect(preset.n3s, `${fil} n3s temp`).toBeGreaterThanOrEqual(45);
|
||||
expect(preset.n3s, `${fil} n3s temp`).toBeLessThanOrEqual(85);
|
||||
}
|
||||
});
|
||||
|
||||
it('all hours are between 1-24', () => {
|
||||
for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {
|
||||
expect(preset.n3f_hours, `${fil} n3f_hours`).toBeGreaterThanOrEqual(1);
|
||||
expect(preset.n3f_hours, `${fil} n3f_hours`).toBeLessThanOrEqual(24);
|
||||
expect(preset.n3s_hours, `${fil} n3s_hours`).toBeGreaterThanOrEqual(1);
|
||||
expect(preset.n3s_hours, `${fil} n3s_hours`).toBeLessThanOrEqual(24);
|
||||
}
|
||||
});
|
||||
|
||||
it('n3s temp is always >= n3f temp for same filament', () => {
|
||||
for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {
|
||||
expect(preset.n3s, `${fil}: n3s should be >= n3f`).toBeGreaterThanOrEqual(preset.n3f);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dry_time formatting', () => {
|
||||
it('formats >= 60 minutes as hours and minutes', () => {
|
||||
expect(formatDryTime(119)).toBe('1h 59m');
|
||||
});
|
||||
|
||||
it('formats exactly 60 minutes as 1h 0m', () => {
|
||||
expect(formatDryTime(60)).toBe('1h 0m');
|
||||
});
|
||||
|
||||
it('formats large values correctly', () => {
|
||||
expect(formatDryTime(750)).toBe('12h 30m');
|
||||
});
|
||||
|
||||
it('formats < 60 minutes as minutes only', () => {
|
||||
expect(formatDryTime(42)).toBe('42m');
|
||||
});
|
||||
|
||||
it('formats 1 minute', () => {
|
||||
expect(formatDryTime(1)).toBe('1m');
|
||||
});
|
||||
|
||||
it('dry_time = 0 means not drying (shows 0m)', () => {
|
||||
// In the UI, dry_time > 0 gates whether the drying bar is shown at all,
|
||||
// so formatDryTime(0) would not be called. But the value itself means "not drying".
|
||||
expect(formatDryTime(0)).toBe('0m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('module type detection — drying button visibility', () => {
|
||||
/**
|
||||
* Mirrors the condition from PrintersPage.tsx:
|
||||
* ams.module_type === 'n3f' || ams.module_type === 'n3s'
|
||||
* The drying button only shows for AMS 2 Pro (n3f) and AMS-HT (n3s).
|
||||
*/
|
||||
function shouldShowDryingButton(moduleType: string): boolean {
|
||||
return moduleType === 'n3f' || moduleType === 'n3s';
|
||||
}
|
||||
|
||||
it('shows for n3f (AMS 2 Pro)', () => {
|
||||
expect(shouldShowDryingButton('n3f')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows for n3s (AMS-HT)', () => {
|
||||
expect(shouldShowDryingButton('n3s')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show for ams (original AMS)', () => {
|
||||
expect(shouldShowDryingButton('ams')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not show for empty string', () => {
|
||||
expect(shouldShowDryingButton('')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not show for unknown types', () => {
|
||||
expect(shouldShowDryingButton('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('temperature clamping', () => {
|
||||
describe('n3f (max 65)', () => {
|
||||
it('clamps value below minimum to 45', () => {
|
||||
expect(clampTemp(30, 'n3f')).toBe(45);
|
||||
});
|
||||
|
||||
it('clamps value above maximum to 65', () => {
|
||||
expect(clampTemp(80, 'n3f')).toBe(65);
|
||||
});
|
||||
|
||||
it('keeps value within range unchanged', () => {
|
||||
expect(clampTemp(55, 'n3f')).toBe(55);
|
||||
});
|
||||
|
||||
it('keeps minimum boundary value', () => {
|
||||
expect(clampTemp(45, 'n3f')).toBe(45);
|
||||
});
|
||||
|
||||
it('keeps maximum boundary value', () => {
|
||||
expect(clampTemp(65, 'n3f')).toBe(65);
|
||||
});
|
||||
});
|
||||
|
||||
describe('n3s (max 85)', () => {
|
||||
it('clamps value below minimum to 45', () => {
|
||||
expect(clampTemp(10, 'n3s')).toBe(45);
|
||||
});
|
||||
|
||||
it('clamps value above maximum to 85', () => {
|
||||
expect(clampTemp(100, 'n3s')).toBe(85);
|
||||
});
|
||||
|
||||
it('keeps value within range unchanged', () => {
|
||||
expect(clampTemp(70, 'n3s')).toBe(70);
|
||||
});
|
||||
|
||||
it('keeps minimum boundary value', () => {
|
||||
expect(clampTemp(45, 'n3s')).toBe(45);
|
||||
});
|
||||
|
||||
it('keeps maximum boundary value', () => {
|
||||
expect(clampTemp(85, 'n3s')).toBe(85);
|
||||
});
|
||||
|
||||
it('allows values above n3f max (e.g. 75)', () => {
|
||||
expect(clampTemp(75, 'n3s')).toBe(75);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,8 @@ export interface AMSTray {
|
||||
tray_uuid: string | null; // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
|
||||
nozzle_temp_min: number | null; // Min nozzle temperature
|
||||
nozzle_temp_max: number | null; // Max nozzle temperature
|
||||
drying_temp: number | null; // RFID-recommended drying temp
|
||||
drying_time: number | null; // RFID-recommended drying time (hours)
|
||||
}
|
||||
|
||||
export interface AMSUnit {
|
||||
@@ -136,6 +138,8 @@ export interface AMSUnit {
|
||||
tray: AMSTray[];
|
||||
serial_number: string; // AMS unit serial number (from MQTT sn field)
|
||||
sw_ver: string; // AMS firmware version (from get_version info.module ams/* entry)
|
||||
dry_time: number; // Minutes remaining (0 = not drying, >0 = drying active)
|
||||
module_type: string; // "ams", "n3f", "n3s"
|
||||
}
|
||||
|
||||
export interface NozzleInfo {
|
||||
@@ -257,6 +261,8 @@ export interface PrinterStatus {
|
||||
developer_mode: boolean | null;
|
||||
// Queue: user has acknowledged plate is cleared for next queued print
|
||||
plate_cleared: boolean;
|
||||
// AMS drying support
|
||||
supports_drying: boolean;
|
||||
}
|
||||
|
||||
export interface PrinterCreate {
|
||||
@@ -2377,6 +2383,18 @@ export const api = {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// AMS Drying Control
|
||||
startDrying: (printerId: number, amsId: number, temp: number, duration: number, filament: string = '') =>
|
||||
request<{ status: string; ams_id: number; temp: number; duration: number }>(
|
||||
`/printers/${printerId}/drying/start?ams_id=${amsId}&temp=${temp}&duration=${duration}&filament=${encodeURIComponent(filament)}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
stopDrying: (printerId: number, amsId: number) =>
|
||||
request<{ status: string; ams_id: number }>(
|
||||
`/printers/${printerId}/drying/stop?ams_id=${amsId}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Skip Objects
|
||||
getPrintableObjects: (printerId: number) =>
|
||||
request<{
|
||||
|
||||
@@ -404,6 +404,19 @@ export default {
|
||||
unknown: 'Unbekannt',
|
||||
failedToStart: 'Erkennung konnte nicht gestartet werden',
|
||||
},
|
||||
// AMS Drying
|
||||
drying: {
|
||||
start: 'Trocknung starten',
|
||||
stop: 'Trocknung stoppen',
|
||||
temperature: 'Temperatur',
|
||||
duration: 'Dauer',
|
||||
hours: 'Stunden',
|
||||
timeRemaining: '{{time}} verbleibend',
|
||||
active: 'Trocknung',
|
||||
notSupported: 'Trocknung nicht unterstützt',
|
||||
startingDrying: 'Trocknung wird gestartet...',
|
||||
stoppingDrying: 'Trocknung wird gestoppt...',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filamente',
|
||||
// Camera
|
||||
|
||||
@@ -404,6 +404,19 @@ export default {
|
||||
unknown: 'Unknown',
|
||||
failedToStart: 'Failed to start discovery',
|
||||
},
|
||||
// AMS Drying
|
||||
drying: {
|
||||
start: 'Start Drying',
|
||||
stop: 'Stop Drying',
|
||||
temperature: 'Temperature',
|
||||
duration: 'Duration',
|
||||
hours: 'hours',
|
||||
timeRemaining: '{{time}} left',
|
||||
active: 'Drying',
|
||||
notSupported: 'Drying not supported',
|
||||
startingDrying: 'Starting drying...',
|
||||
stoppingDrying: 'Stopping drying...',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filaments',
|
||||
// Camera
|
||||
|
||||
@@ -404,6 +404,19 @@ export default {
|
||||
unknown: 'Inconnu',
|
||||
failedToStart: 'Échec du démarrage de la découverte',
|
||||
},
|
||||
// AMS Drying
|
||||
drying: {
|
||||
start: 'Démarrer le séchage',
|
||||
stop: 'Arrêter le séchage',
|
||||
temperature: 'Température',
|
||||
duration: 'Durée',
|
||||
hours: 'heures',
|
||||
timeRemaining: '{{time}} restant',
|
||||
active: 'Séchage',
|
||||
notSupported: 'Séchage non pris en charge',
|
||||
startingDrying: 'Démarrage du séchage...',
|
||||
stoppingDrying: 'Arrêt du séchage...',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filaments',
|
||||
// Camera
|
||||
|
||||
@@ -404,6 +404,19 @@ export default {
|
||||
unknown: 'Sconosciuto',
|
||||
failedToStart: 'Avvio ricerca non riuscito',
|
||||
},
|
||||
// AMS Drying
|
||||
drying: {
|
||||
start: 'Avvia essiccazione',
|
||||
stop: 'Ferma essiccazione',
|
||||
temperature: 'Temperatura',
|
||||
duration: 'Durata',
|
||||
hours: 'ore',
|
||||
timeRemaining: '{{time}} rimanente',
|
||||
active: 'Essiccazione',
|
||||
notSupported: 'Essiccazione non supportata',
|
||||
startingDrying: 'Avvio essiccazione...',
|
||||
stoppingDrying: 'Arresto essiccazione...',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filamenti',
|
||||
// Camera
|
||||
|
||||
@@ -404,6 +404,19 @@ export default {
|
||||
unknown: '不明',
|
||||
failedToStart: '印刷の開始に失敗しました',
|
||||
},
|
||||
// AMS Drying
|
||||
drying: {
|
||||
start: '乾燥開始',
|
||||
stop: '乾燥停止',
|
||||
temperature: '温度',
|
||||
duration: '時間',
|
||||
hours: '時間',
|
||||
timeRemaining: '残り {{time}}',
|
||||
active: '乾燥中',
|
||||
notSupported: '乾燥非対応',
|
||||
startingDrying: '乾燥を開始しています...',
|
||||
stoppingDrying: '乾燥を停止しています...',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'フィラメント',
|
||||
// Camera
|
||||
|
||||
@@ -404,6 +404,19 @@ export default {
|
||||
unknown: 'Desconhecido',
|
||||
failedToStart: 'Falha ao iniciar a descoberta',
|
||||
},
|
||||
// AMS Drying
|
||||
drying: {
|
||||
start: 'Iniciar secagem',
|
||||
stop: 'Parar secagem',
|
||||
temperature: 'Temperatura',
|
||||
duration: 'Duração',
|
||||
hours: 'horas',
|
||||
timeRemaining: '{{time}} restante',
|
||||
active: 'Secagem',
|
||||
notSupported: 'Secagem não suportada',
|
||||
startingDrying: 'Iniciando secagem...',
|
||||
stoppingDrying: 'Parando secagem...',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: 'Filamentos',
|
||||
// Camera
|
||||
|
||||
@@ -404,6 +404,19 @@ export default {
|
||||
unknown: '未知',
|
||||
failedToStart: '启动发现失败',
|
||||
},
|
||||
// AMS Drying
|
||||
drying: {
|
||||
start: '开始干燥',
|
||||
stop: '停止干燥',
|
||||
temperature: '温度',
|
||||
duration: '时长',
|
||||
hours: '小时',
|
||||
timeRemaining: '剩余 {{time}}',
|
||||
active: '干燥中',
|
||||
notSupported: '不支持干燥',
|
||||
startingDrying: '正在启动干燥...',
|
||||
stoppingDrying: '正在停止干燥...',
|
||||
},
|
||||
// Filaments section
|
||||
filaments: '耗材',
|
||||
// Camera
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
Printer as PrinterIcon,
|
||||
Info,
|
||||
Cable,
|
||||
Flame,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -1506,6 +1507,19 @@ export function AmsNameHoverCard({
|
||||
}
|
||||
|
||||
|
||||
// AMS drying presets from BambuStudio filament profiles (idle mode temps)
|
||||
// Format: { n3f temp, n3s temp, n3f hours, n3s hours }
|
||||
const DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
|
||||
'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
|
||||
'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
|
||||
'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
|
||||
'ABS': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'ASA': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'PA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
|
||||
'PC': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'PVA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
|
||||
};
|
||||
|
||||
function PrinterCard({
|
||||
printer,
|
||||
hideIfDisconnected,
|
||||
@@ -1568,6 +1582,13 @@ function PrinterCard({
|
||||
const [showPrinterInfo, setShowPrinterInfo] = useState(false);
|
||||
const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []);
|
||||
const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null);
|
||||
// AMS drying popover state: which AMS unit has the popover open
|
||||
const [dryingPopoverAmsId, setDryingPopoverAmsId] = useState<number | null>(null);
|
||||
const [dryingPopoverModuleType, setDryingPopoverModuleType] = useState<string>('n3f');
|
||||
const [dryingFilament, setDryingFilament] = useState('PLA');
|
||||
const [dryingTemp, setDryingTemp] = useState(50);
|
||||
const [dryingDuration, setDryingDuration] = useState(4);
|
||||
const [dryingPopoverPos, setDryingPopoverPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
const [isDropUploading, setIsDropUploading] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
@@ -1865,6 +1886,25 @@ function PrinterCard({
|
||||
},
|
||||
});
|
||||
|
||||
// AMS drying mutations
|
||||
const startDryingMutation = useMutation({
|
||||
mutationFn: ({ amsId, temp, duration, filament }: { amsId: number; temp: number; duration: number; filament: string }) =>
|
||||
api.startDrying(printer.id, amsId, temp, duration, filament),
|
||||
onSuccess: () => {
|
||||
setDryingPopoverAmsId(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
|
||||
});
|
||||
|
||||
const stopDryingMutation = useMutation({
|
||||
mutationFn: (amsId: number) => api.stopDrying(printer.id, amsId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
|
||||
});
|
||||
|
||||
// Smart plug control mutations
|
||||
const powerControlMutation = useMutation({
|
||||
mutationFn: (action: 'on' | 'off') =>
|
||||
@@ -3056,9 +3096,63 @@ function PrinterCard({
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s) */}
|
||||
{status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (ams.dry_time > 0) {
|
||||
stopDryingMutation.mutate(ams.id);
|
||||
} else if (dryingPopoverAmsId === ams.id) {
|
||||
setDryingPopoverAmsId(null);
|
||||
} else {
|
||||
const firstTray = ams.tray.find(t => t.tray_type);
|
||||
const filType = firstTray?.tray_type || 'PLA';
|
||||
const preset = DRYING_PRESETS[filType] || DRYING_PRESETS['PLA'];
|
||||
const moduleType = ams.module_type as 'n3f' | 'n3s';
|
||||
setDryingFilament(filType);
|
||||
setDryingTemp(preset[moduleType] || preset.n3f);
|
||||
setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
|
||||
setDryingPopoverModuleType(ams.module_type);
|
||||
setDryingPopoverAmsId(ams.id);
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setDryingPopoverPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) });
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
|
||||
ams.dry_time > 0
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
title={ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
|
||||
>
|
||||
<Flame className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Drying status bar */}
|
||||
{ams.dry_time > 0 && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 mb-1 bg-amber-500/10 border border-amber-500/20 rounded text-[9px]">
|
||||
<Flame className="w-3 h-3 text-amber-400 shrink-0" />
|
||||
<span className="text-amber-400 font-medium">{t('printers.drying.active')}</span>
|
||||
<span className="text-amber-300/70">
|
||||
{t('printers.drying.timeRemaining', {
|
||||
time: ams.dry_time >= 60
|
||||
? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
|
||||
: `${ams.dry_time}m`
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => stopDryingMutation.mutate(ams.id)}
|
||||
disabled={stopDryingMutation.isPending}
|
||||
className="ml-auto text-amber-400 hover:text-amber-300 transition-colors disabled:opacity-50"
|
||||
title={t('printers.drying.stop')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Slots grid: 4 columns - always render 4 slots */}
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{[0, 1, 2, 3].map((slotIdx) => {
|
||||
@@ -3367,7 +3461,7 @@ function PrinterCard({
|
||||
|
||||
return (
|
||||
<div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
|
||||
{/* Row 1: Label + Nozzle */}
|
||||
{/* Row 1: Label + Nozzle + Drying */}
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
{/* AMS name — hover to see serial, firmware, and edit friendly name */}
|
||||
<AmsNameHoverCard
|
||||
@@ -3385,7 +3479,60 @@ function PrinterCard({
|
||||
{isDualNozzle && (isLeftNozzle || isRightNozzle) && (
|
||||
<NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
|
||||
)}
|
||||
{/* Drying button for HT AMS */}
|
||||
{status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (ams.dry_time > 0) {
|
||||
stopDryingMutation.mutate(ams.id);
|
||||
} else if (dryingPopoverAmsId === ams.id) {
|
||||
setDryingPopoverAmsId(null);
|
||||
} else {
|
||||
const firstTray = ams.tray.find(t => t.tray_type);
|
||||
const filType = firstTray?.tray_type || 'PLA';
|
||||
const preset = DRYING_PRESETS[filType] || DRYING_PRESETS['PLA'];
|
||||
const moduleType = ams.module_type as 'n3f' | 'n3s';
|
||||
setDryingFilament(filType);
|
||||
setDryingTemp(firstTray?.drying_temp || preset[moduleType] || preset.n3f);
|
||||
setDryingDuration(firstTray?.drying_time || (moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours));
|
||||
setDryingPopoverModuleType(ams.module_type);
|
||||
setDryingPopoverAmsId(ams.id);
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setDryingPopoverPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) });
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
|
||||
ams.dry_time > 0
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
title={ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
|
||||
>
|
||||
<Flame className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* HT AMS drying status bar */}
|
||||
{ams.dry_time > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 mb-1 bg-amber-500/10 border border-amber-500/20 rounded text-[9px] whitespace-nowrap overflow-hidden">
|
||||
<Flame className="w-3 h-3 text-amber-400 shrink-0" />
|
||||
<span className="text-amber-300/70 text-[8px] truncate">
|
||||
{ams.dry_time >= 60
|
||||
? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
|
||||
: `${ams.dry_time}m`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => stopDryingMutation.mutate(ams.id)}
|
||||
disabled={stopDryingMutation.isPending}
|
||||
className="ml-auto text-amber-400 hover:text-amber-300 transition-colors disabled:opacity-50 shrink-0"
|
||||
title={t('printers.drying.stop')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Row 2: Slot (left) + Stats (right stacked) */}
|
||||
<div className="flex gap-1.5 max-[550px]:flex-col max-[550px]:items-start">
|
||||
{/* Slot wrapper with menu button, dropdown, and loading overlay */}
|
||||
@@ -4444,6 +4591,128 @@ function PrinterCard({
|
||||
onClick={() => setAmsSlotMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AMS Drying Popover — fixed position to avoid overflow/z-index issues */}
|
||||
{dryingPopoverAmsId !== null && dryingPopoverPos && (() => {
|
||||
const maxTemp = dryingPopoverModuleType === 'n3s' ? 85 : 65;
|
||||
const sliderMin = 35;
|
||||
const sliderMax = maxTemp + 10;
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[100]" onClick={() => setDryingPopoverAmsId(null)} />
|
||||
{/* Popover */}
|
||||
<div
|
||||
className="fixed z-[101] w-[240px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl overflow-hidden"
|
||||
style={{ top: dryingPopoverPos.top, left: dryingPopoverPos.left }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-bambu-dark-tertiary">
|
||||
<Flame className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span className="text-xs text-white font-medium">{t('printers.drying.start')}</span>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="px-3 py-2.5 space-y-2.5">
|
||||
{/* Filament type select */}
|
||||
<div>
|
||||
<label className="text-[10px] text-bambu-gray mb-1 block">{t('printers.filaments')}</label>
|
||||
<select
|
||||
value={dryingFilament}
|
||||
onChange={e => {
|
||||
const fil = e.target.value;
|
||||
setDryingFilament(fil);
|
||||
const preset = DRYING_PRESETS[fil];
|
||||
if (preset) {
|
||||
const key = dryingPopoverModuleType === 'n3s' ? 'n3s' : 'n3f';
|
||||
setDryingTemp(preset[key]);
|
||||
setDryingDuration(dryingPopoverModuleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs focus:outline-none focus:border-amber-500/50"
|
||||
>
|
||||
{Object.keys(DRYING_PRESETS).map(fil => (
|
||||
<option key={fil} value={fil}>{fil}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[10px] text-bambu-gray">{t('printers.drying.temperature')}</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={45}
|
||||
max={maxTemp}
|
||||
value={dryingTemp}
|
||||
onChange={e => setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value) || 45)))}
|
||||
className="w-12 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span className="text-[10px] text-bambu-gray">°C</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={sliderMin}
|
||||
max={sliderMax}
|
||||
value={dryingTemp}
|
||||
onChange={e => setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value))))}
|
||||
className="w-full h-1 accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-bambu-gray/50 mt-0.5">
|
||||
<span>45°C</span>
|
||||
<span>{maxTemp}°C</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Duration */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[10px] text-bambu-gray">{t('printers.drying.duration')}</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={dryingDuration}
|
||||
onChange={e => setDryingDuration(Math.min(24, Math.max(1, Number(e.target.value) || 1)))}
|
||||
className="w-10 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span className="text-[10px] text-bambu-gray">{t('printers.drying.hours')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={24}
|
||||
value={dryingDuration}
|
||||
onChange={e => setDryingDuration(Number(e.target.value))}
|
||||
className="w-full h-1 accent-amber-500 cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-bambu-gray/50 mt-0.5">
|
||||
<span>1h</span>
|
||||
<span>24h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (dryingPopoverAmsId !== null) {
|
||||
startDryingMutation.mutate({ amsId: dryingPopoverAmsId, temp: dryingTemp, duration: dryingDuration, filament: dryingFilament });
|
||||
}
|
||||
}}
|
||||
disabled={startDryingMutation.isPending}
|
||||
className="w-full py-1.5 bg-amber-500 hover:bg-amber-400 text-white text-xs font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{startDryingMutation.isPending ? t('printers.drying.startingDrying') : t('printers.drying.start')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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
@@ -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-tM2oTG9k.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BoLtXYT2.css">
|
||||
<script type="module" crossorigin src="/assets/index-_97yluWW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-_3X9byk-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user