Wiring up AMS module

This commit is contained in:
Martin Ziegler
2025-12-04 17:59:54 +01:00
parent b8d32c04a7
commit 6a6ce3cc22
18 changed files with 959 additions and 469 deletions
+26 -2
View File
@@ -133,6 +133,12 @@ class MoveRequest(ConfirmableRequest):
class AMSLoadRequest(BaseModel):
tray_id: int = Field(..., ge=0, le=254, description="Tray ID (0-15 for AMS, 254 for external)")
extruder_id: int | None = Field(default=None, ge=0, le=1, description="Extruder ID for dual-nozzle printers (0=right, 1=left)")
class AMSRefreshTrayRequest(BaseModel):
ams_id: int = Field(..., ge=0, le=128, description="AMS unit ID (0-3, or 128 for H2D external)")
tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
class GcodeRequest(ConfirmableRequest):
@@ -587,10 +593,11 @@ async def ams_load_filament(
if client.state.state == "RUNNING":
raise HTTPException(status_code=400, detail="Cannot change filament during print")
success = client.ams_load_filament(request.tray_id)
success = client.ams_load_filament(request.tray_id, request.extruder_id)
extruder_info = f" to extruder {request.extruder_id}" if request.extruder_id is not None else ""
return ControlResponse(
success=success,
message=f"Loading filament from tray {request.tray_id}" if success else "Failed to load filament"
message=f"Loading filament from tray {request.tray_id}{extruder_info}" if success else "Failed to load filament"
)
@@ -614,6 +621,23 @@ async def ams_unload_filament(
)
@router.post("/{printer_id}/control/ams/refresh-tray", response_model=ControlResponse)
async def ams_refresh_tray(
printer_id: int,
request: AMSRefreshTrayRequest,
db: AsyncSession = Depends(get_db),
):
"""Trigger RFID re-read for a specific AMS tray."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
success = client.ams_refresh_tray(request.ams_id, request.tray_id)
return ControlResponse(
success=success,
message=f"Refreshing AMS {request.ams_id} tray {request.tray_id}" if success else "Failed to refresh tray"
)
# =============================================================================
# Advanced: G-code Command
# =============================================================================
+193 -2
View File
@@ -13,6 +13,7 @@ from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.core.config import settings
from backend.app.models.printer import Printer
from backend.app.models.slot_preset import SlotPresetMapping
from backend.app.schemas.printer import (
PrinterCreate,
PrinterUpdate,
@@ -156,21 +157,54 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
ams_exists = False
raw_data = state.raw_data or {}
if "ams" in raw_data:
if "ams" in raw_data and isinstance(raw_data["ams"], list):
ams_exists = True
for ams_data in raw_data["ams"]:
# Skip if ams_data is not a dict (defensive check)
if not isinstance(ams_data, dict):
continue
trays = []
for tray_data in ams_data.get("tray", []):
# Filter out empty/invalid tag values
tag_uid = tray_data.get("tag_uid", "")
if tag_uid in ("", "0000000000000000"):
tag_uid = None
tray_uuid = tray_data.get("tray_uuid", "")
if tray_uuid in ("", "00000000000000000000000000000000"):
tray_uuid = None
trays.append(AMSTray(
id=tray_data.get("id", 0),
tray_color=tray_data.get("tray_color"),
tray_type=tray_data.get("tray_type"),
tray_sub_brands=tray_data.get("tray_sub_brands"),
tray_id_name=tray_data.get("tray_id_name"),
tray_info_idx=tray_data.get("tray_info_idx"),
remain=tray_data.get("remain", 0),
k=tray_data.get("k"),
tag_uid=tag_uid,
tray_uuid=tray_uuid,
nozzle_temp_min=tray_data.get("nozzle_temp_min"),
nozzle_temp_max=tray_data.get("nozzle_temp_max"),
))
# Prefer humidity_raw (percentage) over humidity (index 1-5)
# humidity_raw is the actual percentage value from the sensor
humidity_raw = ams_data.get("humidity_raw")
humidity_idx = ams_data.get("humidity")
# Use humidity_raw if available, otherwise fall back to humidity index
humidity_value = None
if humidity_raw is not None:
try:
humidity_value = int(humidity_raw)
except (ValueError, TypeError):
pass
if humidity_value is None and humidity_idx is not None:
try:
humidity_value = int(humidity_idx)
except (ValueError, TypeError):
pass
ams_units.append(AMSUnit(
id=ams_data.get("id", 0),
humidity=ams_data.get("humidity"),
humidity=humidity_value,
temp=ams_data.get("temp"),
tray=trays,
))
@@ -178,12 +212,24 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
# Virtual tray (external spool holder) - comes from vt_tray in raw_data
if "vt_tray" in raw_data:
vt_data = raw_data["vt_tray"]
# Filter out empty/invalid tag values for vt_tray
vt_tag_uid = vt_data.get("tag_uid", "")
if vt_tag_uid in ("", "0000000000000000"):
vt_tag_uid = None
vt_tray_uuid = vt_data.get("tray_uuid", "")
if vt_tray_uuid in ("", "00000000000000000000000000000000"):
vt_tray_uuid = None
vt_tray = AMSTray(
id=254, # Virtual tray ID
tray_color=vt_data.get("tray_color"),
tray_type=vt_data.get("tray_type"),
tray_sub_brands=vt_data.get("tray_sub_brands"),
remain=vt_data.get("remain", 0),
k=vt_data.get("k"),
tag_uid=vt_tag_uid,
tray_uuid=vt_tray_uuid,
nozzle_temp_min=vt_data.get("nozzle_temp_min"),
nozzle_temp_max=vt_data.get("nozzle_temp_max"),
)
# Convert nozzle info to response format
@@ -214,6 +260,18 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
filament_tangle_detect=state.print_options.filament_tangle_detect,
)
# Get AMS mapping from raw_data (which AMS is connected to which nozzle)
ams_mapping = raw_data.get("ams_mapping", [])
# Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
ams_extruder_map = raw_data.get("ams_extruder_map", {})
logger.debug(f"API returning ams_mapping: {ams_mapping}, ams_extruder_map: {ams_extruder_map}")
# tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
# Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
# No conversion needed - just use the raw value directly
tray_now = state.tray_now
logger.debug(f"Using tray_now directly as global ID: {tray_now}")
return PrinterStatus(
id=printer_id,
name=printer.name,
@@ -245,6 +303,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
speed_level=state.speed_level,
chamber_light=state.chamber_light,
active_extruder=state.active_extruder,
ams_mapping=ams_mapping,
ams_extruder_map=ams_extruder_map,
tray_now=tray_now,
)
@@ -701,3 +762,133 @@ async def start_calibration(
"nozzle_offset": nozzle_offset,
"high_temp_heatbed": high_temp_heatbed,
}
# ============================================================================
# Slot Preset Mapping Endpoints
# ============================================================================
@router.get("/{printer_id}/slot-presets")
async def get_slot_presets(
printer_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get all saved slot-to-preset mappings for a printer."""
result = await db.execute(
select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id)
)
mappings = result.scalars().all()
return {
mapping.ams_id * 4 + mapping.tray_id: {
"ams_id": mapping.ams_id,
"tray_id": mapping.tray_id,
"preset_id": mapping.preset_id,
"preset_name": mapping.preset_name,
}
for mapping in mappings
}
@router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
async def get_slot_preset(
printer_id: int,
ams_id: int,
tray_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get the saved preset for a specific slot."""
result = await db.execute(
select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer_id,
SlotPresetMapping.ams_id == ams_id,
SlotPresetMapping.tray_id == tray_id,
)
)
mapping = result.scalar_one_or_none()
if not mapping:
return None
return {
"ams_id": mapping.ams_id,
"tray_id": mapping.tray_id,
"preset_id": mapping.preset_id,
"preset_name": mapping.preset_name,
}
@router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
async def save_slot_preset(
printer_id: int,
ams_id: int,
tray_id: int,
preset_id: str,
preset_name: str,
db: AsyncSession = Depends(get_db),
):
"""Save a preset mapping for a specific slot."""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
if not result.scalar_one_or_none():
raise HTTPException(404, "Printer not found")
# Check for existing mapping
result = await db.execute(
select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer_id,
SlotPresetMapping.ams_id == ams_id,
SlotPresetMapping.tray_id == tray_id,
)
)
mapping = result.scalar_one_or_none()
if mapping:
# Update existing
mapping.preset_id = preset_id
mapping.preset_name = preset_name
else:
# Create new
mapping = SlotPresetMapping(
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
preset_id=preset_id,
preset_name=preset_name,
)
db.add(mapping)
await db.commit()
await db.refresh(mapping)
return {
"ams_id": mapping.ams_id,
"tray_id": mapping.tray_id,
"preset_id": mapping.preset_id,
"preset_name": mapping.preset_name,
}
@router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
async def delete_slot_preset(
printer_id: int,
ams_id: int,
tray_id: int,
db: AsyncSession = Depends(get_db),
):
"""Delete a saved preset mapping for a slot."""
result = await db.execute(
select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer_id,
SlotPresetMapping.ams_id == ams_id,
SlotPresetMapping.tray_id == tray_id,
)
)
mapping = result.scalar_one_or_none()
if mapping:
await db.delete(mapping)
await db.commit()
return {"success": True}
+37
View File
@@ -0,0 +1,37 @@
"""Model for storing AMS slot to filament preset mappings.
This stores the user's preferred filament preset for each AMS slot,
similar to how Bambu Studio remembers preset selections.
"""
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, func, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class SlotPresetMapping(Base):
"""Maps an AMS slot to a cloud filament preset."""
__tablename__ = "slot_preset_mappings"
__table_args__ = (
UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"),
)
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
ams_id: Mapped[int] = mapped_column(Integer) # AMS unit ID (0, 1, 2, 3)
tray_id: Mapped[int] = mapped_column(Integer) # Tray ID within AMS (0-3)
preset_id: Mapped[str] = mapped_column(String(100)) # Cloud preset setting_id
preset_name: Mapped[str] = mapped_column(String(200)) # Preset name for display
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
# Relationship
printer: Mapped["Printer"] = relationship()
from backend.app.models.printer import Printer # noqa: E402
+6
View File
@@ -121,3 +121,9 @@ class PrinterStatus(BaseModel):
chamber_light: bool = False
# Active extruder for dual nozzle (0=right, 1=left)
active_extruder: int = 0
# AMS mapping for dual nozzle: which AMS is connected to which nozzle
ams_mapping: list[int] = []
# Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
ams_extruder_map: dict[str, int] = {}
# Currently loaded tray (global ID): 254 = external spool, 255 = no filament
tray_now: int = 255
+175 -6
View File
@@ -112,6 +112,10 @@ class PrinterState:
chamber_light: bool = False
# Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow
active_extruder: int = 0
# Currently loaded tray (global ID): 254 = external spool, 255 = no filament
tray_now: int = 255
# Pending load target - used to track what tray we're loading for H2D disambiguation
pending_tray_target: int | None = None
# Stage name mapping from BambuStudio DeviceManager.cpp
@@ -236,6 +240,11 @@ class BambuMQTTClient:
self._xcam_hold_start: dict[str, float] = {}
self._xcam_hold_time: float = 3.0 # Ignore incoming data for 3 seconds after command
# Track last requested tray ID for H2D dual-nozzle printers
# H2D only reports slot number (0-3) in tray_now, not global tray ID
# We use our tracked value to resolve the correct global ID
self._last_load_tray_id: int | None = None
@property
def topic_subscribe(self) -> str:
return f"device/{self.serial_number}/report"
@@ -346,7 +355,12 @@ class BambuMQTTClient:
# Handle vt_tray (virtual tray / external spool) data
if "vt_tray" in print_data:
self.state.raw_data["vt_tray"] = print_data["vt_tray"]
vt_tray = print_data["vt_tray"]
self.state.raw_data["vt_tray"] = vt_tray
# Log vt_tray to investigate per-extruder data for H2D
if not hasattr(self, '_vt_tray_logged') or not self._vt_tray_logged:
logger.info(f"[{self.serial_number}] vt_tray data: {vt_tray}")
self._vt_tray_logged = True
# Check for K-profile response (extrusion_cali)
if "command" in print_data:
@@ -572,6 +586,63 @@ class BambuMQTTClient:
# Handle nested ams structure: {"ams": {"ams": [...]}} or {"ams": [...]}
if isinstance(ams_data, dict) and "ams" in ams_data:
ams_list = ams_data["ams"]
# Log all AMS dict fields to debug tray_now for H2D dual-nozzle
non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
if non_list_fields:
logger.info(f"[{self.serial_number}] AMS dict fields: {non_list_fields}")
# Parse tray_now from AMS dict - this is the currently loaded tray global ID
if "tray_now" in ams_data:
raw_tray_now = ams_data["tray_now"]
# Convert string to int if needed
if isinstance(raw_tray_now, str):
try:
parsed_tray_now = int(raw_tray_now)
except ValueError:
parsed_tray_now = 255
else:
parsed_tray_now = raw_tray_now if raw_tray_now is not None else 255
# H2D dual-nozzle printers report only slot number (0-3), not global tray ID
# Use pending_tray_target from our load command tracking for disambiguation
if parsed_tray_now >= 0 and parsed_tray_now <= 3:
# Check if we have a pending target that matches this slot
pending_target = self.state.pending_tray_target
if pending_target is not None:
pending_slot = pending_target % 4
if pending_slot == parsed_tray_now:
# Slot matches our pending target - use the full global ID
logger.info(
f"[{self.serial_number}] H2D tray_now disambiguation: "
f"slot {parsed_tray_now} matches pending_tray_target {pending_target} -> using global ID {pending_target}"
)
self.state.tray_now = pending_target
# Clear pending target now that load is confirmed
self.state.pending_tray_target = None
else:
# Slot doesn't match our pending target - something changed, use slot as-is
logger.warning(
f"[{self.serial_number}] H2D tray_now: slot {parsed_tray_now} doesn't match "
f"pending_tray_target {pending_target} (slot {pending_slot}) - using slot as global ID"
)
self.state.tray_now = parsed_tray_now
# Clear pending target since it's stale
self.state.pending_tray_target = None
else:
# No pending target, use slot number as-is
# This happens when filament was loaded before app started or via printer touchscreen
logger.debug(
f"[{self.serial_number}] H2D tray_now: no pending_tray_target, "
f"using slot {parsed_tray_now} as global ID (may be incorrect for H2D)"
)
self.state.tray_now = parsed_tray_now
else:
# tray_now > 3 means it's already a global ID, or 255 means unloaded
if parsed_tray_now == 255:
# Filament unloaded - clear pending target
self.state.pending_tray_target = None
self.state.tray_now = parsed_tray_now
logger.debug(f"[{self.serial_number}] tray_now updated: {self.state.tray_now}")
elif isinstance(ams_data, list):
ams_list = ams_data
else:
@@ -582,6 +653,35 @@ class BambuMQTTClient:
self.state.raw_data["ams"] = ams_list
logger.debug(f"[{self.serial_number}] Stored AMS data with {len(ams_list)} units")
# Extract ams_extruder_map from each AMS unit's info field
# According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
# Log AMS unit fields once to discover available fields
if not hasattr(self, '_ams_fields_logged') and ams_list:
first_unit = ams_list[0]
logger.info(f"[{self.serial_number}] AMS unit fields: {sorted(first_unit.keys())}")
for ams_unit in ams_list:
ams_id = ams_unit.get("id")
unit_info = {k: v for k, v in ams_unit.items() if k != "tray"}
logger.info(f"[{self.serial_number}] AMS {ams_id} data: {unit_info}")
self._ams_fields_logged = True
ams_extruder_map = {}
for ams_unit in ams_list:
ams_id = ams_unit.get("id")
info = ams_unit.get("info")
if ams_id is not None and info is not None:
try:
info_val = int(info) if isinstance(info, str) else info
# Extract bit 8 for extruder assignment (0=right, 1=left)
extruder_id = (info_val >> 8) & 0x1
ams_extruder_map[str(ams_id)] = extruder_id
logger.debug(f"[{self.serial_number}] AMS {ams_id} info={info_val} -> extruder {extruder_id}")
except (ValueError, TypeError):
pass
if ams_extruder_map:
self.state.raw_data["ams_extruder_map"] = ams_extruder_map
logger.debug(f"[{self.serial_number}] ams_extruder_map: {ams_extruder_map}")
# Create a hash of relevant AMS data to detect changes
ams_hash_data = []
for ams_unit in ams_list:
@@ -649,6 +749,11 @@ class BambuMQTTClient:
logger.info(f"[{self.serial_number}] ALL print data keys ({len(all_keys)}): {all_keys}")
self._temp_fields_logged = True
# Log vir_slot data (once) - this may contain per-extruder slot mapping for H2D
if "vir_slot" in data and not hasattr(self, '_vir_slot_logged'):
logger.info(f"[{self.serial_number}] vir_slot data: {data['vir_slot']}")
self._vir_slot_logged = True
# Log nozzle hardware info fields (once)
nozzle_fields = {k: v for k, v in data.items() if 'nozzle' in k.lower() or 'hw' in k.lower() or 'extruder' in k.lower() or 'upgrade' in k.lower()}
if nozzle_fields and not hasattr(self, '_nozzle_fields_logged'):
@@ -1077,14 +1182,17 @@ class BambuMQTTClient:
if "diameter" in nozzle:
self.state.nozzles[idx].nozzle_diameter = str(nozzle["diameter"])
# Preserve AMS and vt_tray data when updating raw_data
# Preserve AMS, vt_tray, and ams_extruder_map data when updating raw_data
ams_data = self.state.raw_data.get("ams")
vt_tray_data = self.state.raw_data.get("vt_tray")
ams_extruder_map_data = self.state.raw_data.get("ams_extruder_map")
self.state.raw_data = data
if ams_data is not None:
self.state.raw_data["ams"] = ams_data
if vt_tray_data is not None:
self.state.raw_data["vt_tray"] = vt_tray_data
if ams_extruder_map_data is not None:
self.state.raw_data["ams_extruder_map"] = ams_extruder_map_data
# Log state transitions for debugging
if "gcode_state" in data:
@@ -2178,11 +2286,13 @@ class BambuMQTTClient:
"""
return self.send_gcode("M17")
def ams_load_filament(self, tray_id: int) -> bool:
def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:
"""Load filament from a specific AMS tray.
Args:
tray_id: Tray ID (0-15 for AMS slots, or 254 for external spool)
tray_id: Global tray ID (0-15 for AMS slots, or 254 for external spool)
extruder_id: Extruder ID for dual-nozzle printers (0=right, 1=left).
If None, defaults to 0.
Returns:
True if command was sent, False otherwise
@@ -2191,15 +2301,40 @@ class BambuMQTTClient:
logger.warning(f"[{self.serial_number}] Cannot load filament: not connected")
return False
# Default to extruder 0 if not specified
curr_nozzle = extruder_id if extruder_id is not None else 0
# Convert global tray_id to ams_id and slot_id
# External spool is special case (254)
if tray_id == 254:
ams_id = 255 # External spool
slot_id = 254
else:
ams_id = tray_id // 4 # AMS unit (0, 1, 2, 3...)
slot_id = tray_id % 4 # Slot within AMS (0, 1, 2, 3)
# Build command with ams_id and slot_id format (per HA-Bambulab integration)
command = {
"print": {
"command": "ams_change_filament",
"target": tray_id,
"target": tray_id, # Keep target for compatibility
"ams_id": ams_id,
"slot_id": slot_id,
"curr_temp": 220,
"tar_temp": 220,
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(command))
logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id}")
logger.info(f"[{self.serial_number}] Loading filament from AMS {ams_id} slot {slot_id} (global tray {tray_id})")
# Track this load request for H2D dual-nozzle disambiguation
# H2D reports only slot number (0-3) in tray_now, so we use our tracked value
self._last_load_tray_id = tray_id
self.state.pending_tray_target = tray_id
logger.info(f"[{self.serial_number}] Set pending_tray_target={tray_id} for H2D disambiguation")
return True
def ams_unload_filament(self) -> bool:
@@ -2221,6 +2356,12 @@ class BambuMQTTClient:
}
self._client.publish(self.topic_publish, json.dumps(command))
logger.info(f"[{self.serial_number}] Unloading filament")
# Clear tracked load request since we're unloading
self._last_load_tray_id = None
self.state.pending_tray_target = None
logger.info(f"[{self.serial_number}] Cleared pending_tray_target (unload)")
return True
def ams_control(self, action: str) -> bool:
@@ -2251,6 +2392,34 @@ class BambuMQTTClient:
logger.info(f"[{self.serial_number}] AMS control: {action}")
return True
def ams_refresh_tray(self, ams_id: int, tray_id: int) -> bool:
"""Trigger RFID re-read for a specific AMS tray.
Args:
ams_id: AMS unit ID (0-3, or 128 for H2D external tray)
tray_id: Tray ID within the AMS (0-3)
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot refresh AMS tray: not connected")
return False
# Use ams_get_rfid command to trigger RFID re-read
# This command is used by Bambu Studio to re-read the RFID tag
command = {
"print": {
"command": "ams_get_rfid",
"ams_id": ams_id,
"slot_id": tray_id,
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(command))
logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}")
return True
def set_timelapse(self, enable: bool) -> bool:
"""Enable or disable timelapse recording.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 166 KiB

@@ -57,9 +57,12 @@ export interface MaterialSettings {
function hexToRgb(hex: string | null): string {
if (!hex) return 'rgb(128, 128, 128)';
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
const rParsed = parseInt(cleanHex.substring(0, 2), 16);
const gParsed = parseInt(cleanHex.substring(2, 4), 16);
const bParsed = parseInt(cleanHex.substring(4, 6), 16);
const r = isNaN(rParsed) ? 128 : rParsed;
const g = isNaN(gParsed) ? 128 : gParsed;
const b = isNaN(bParsed) ? 128 : bParsed;
return `rgb(${r}, ${g}, ${b})`;
}
+6 -3
View File
@@ -13,9 +13,12 @@ function hexToRgb(hex: string | null): string {
if (!hex) return 'rgb(128, 128, 128)';
// Handle RRGGBBAA format
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
const rParsed = parseInt(cleanHex.substring(0, 2), 16);
const gParsed = parseInt(cleanHex.substring(2, 4), 16);
const bParsed = parseInt(cleanHex.substring(4, 6), 16);
const r = isNaN(rParsed) ? 128 : rParsed;
const g = isNaN(gParsed) ? 128 : gParsed;
const b = isNaN(bParsed) ? 128 : bParsed;
return `rgb(${r}, ${g}, ${b})`;
}
@@ -22,20 +22,29 @@ interface AMSSectionDualProps {
function hexToRgb(hex: string | null): string {
if (!hex) return 'rgb(128, 128, 128)';
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
const rParsed = parseInt(cleanHex.substring(0, 2), 16);
const gParsed = parseInt(cleanHex.substring(2, 4), 16);
const bParsed = parseInt(cleanHex.substring(4, 6), 16);
const r = isNaN(rParsed) ? 128 : rParsed;
const g = isNaN(gParsed) ? 128 : gParsed;
const b = isNaN(bParsed) ? 128 : bParsed;
return `rgb(${r}, ${g}, ${b})`;
}
function isLightColor(hex: string | null): boolean {
if (!hex) return false;
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 0;
const g = parseInt(cleanHex.substring(2, 4), 16) || 0;
const b = parseInt(cleanHex.substring(4, 6), 16) || 0;
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5;
// Ensure we have a valid 6-char hex
if (cleanHex.length < 6) return false;
const rParsed = parseInt(cleanHex.substring(0, 2), 16);
const gParsed = parseInt(cleanHex.substring(2, 4), 16);
const bParsed = parseInt(cleanHex.substring(4, 6), 16);
// If any parsing fails, treat as dark
if (isNaN(rParsed) || isNaN(gParsed) || isNaN(bParsed)) return false;
// Use relative luminance formula (WCAG)
const luminance = (0.299 * rParsed + 0.587 * gParsed + 0.114 * bParsed) / 255;
// Lower threshold (0.45) to ensure more colors get white text for better contrast
return luminance > 0.45;
}
// Single humidity icon that fills based on level
@@ -73,27 +82,74 @@ interface StepInfo {
function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
// Track the highest progress step reached to show proper progression
// 0 = initial, 1 = push started, 2 = heating, 3 = purging
const [progressStep, setProgressStep] = useState(0);
const prevStageRef = useRef(currentStage);
// Determine step status based on current stage
// When stage is -1 (initial/waiting), show first step as in_progress
// Update progress based on stage transitions
useEffect(() => {
// For loading: track progression through stages
if (isLoading) {
if (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_CHANGING_FILAMENT) {
// Push/loading stage - at least step 1
if (progressStep < 1) setProgressStep(1);
} else if (currentStage === STAGE_HEATING_NOZZLE) {
// Heating stage - at least step 2
if (progressStep < 2) setProgressStep(2);
} else if (progressStep >= 2 && currentStage === STAGE_CHANGING_FILAMENT) {
// After heating, back to changing = purge stage
setProgressStep(3);
}
} else {
// For unloading: track heating -> unloading
if (currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
if (progressStep < 1) setProgressStep(1);
} else if (currentStage === STAGE_FILAMENT_UNLOADING) {
if (progressStep < 2) setProgressStep(2);
}
}
// Reset progress when stage returns to idle
if (currentStage === -1 && prevStageRef.current !== -1) {
// Operation completed, reset for next time
setProgressStep(0);
}
prevStageRef.current = currentStage;
}, [currentStage, isLoading, progressStep]);
// Initialize progress to step 1 when card first shows
useEffect(() => {
if (progressStep === 0) {
setProgressStep(1);
}
}, []);
// Determine step status based on tracked progress
const getLoadingSteps = (): StepInfo[] => {
// Loading sequence: Heat nozzle (7) -> Push filament (24) -> Purge (still 24 or complete)
// Loading sequence: Push filament -> Heat nozzle -> Purge
let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending';
if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
// Initial state or heating - step 1 is active
step1Status = 'in_progress';
} else if (currentStage === STAGE_FILAMENT_LOADING) {
// Loading filament - step 1 done, step 2 active
if (progressStep >= 3) {
// Purging - steps 1 & 2 done, step 3 active
step1Status = 'completed';
step2Status = 'completed';
step3Status = 'in_progress';
} else if (progressStep >= 2) {
// Heating - step 1 done, step 2 active
step1Status = 'completed';
step2Status = 'in_progress';
} else if (progressStep >= 1) {
// Pushing - step 1 active
step1Status = 'in_progress';
}
return [
{ label: 'Heat the nozzle', stepNumber: 1, status: step1Status },
{ label: 'Push new filament into extruder', stepNumber: 2, status: step2Status },
{ label: 'Push new filament into extruder', stepNumber: 1, status: step1Status },
{ label: 'Heat the nozzle', stepNumber: 2, status: step2Status },
{ label: 'Purge old filament', stepNumber: 3, status: step3Status },
];
};
@@ -102,13 +158,13 @@ function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChange
let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
// Initial state or heating - step 1 is active
step1Status = 'in_progress';
} else if (currentStage === STAGE_FILAMENT_UNLOADING) {
// Unloading filament - step 1 done, step 2 active
if (progressStep >= 2) {
// Unloading - step 1 done, step 2 active
step1Status = 'completed';
step2Status = 'in_progress';
} else if (progressStep >= 1) {
// Heating - step 1 active
step1Status = 'in_progress';
}
return [
@@ -340,8 +396,8 @@ function AMSPanelContent({
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
} ${isEmpty ? 'opacity-50' : 'cursor-pointer'}`}
>
{/* Fill level indicator - only for Bambu filaments with valid remain data */}
{!isEmpty && tray.tray_uuid && tray.remain >= 0 && (
{/* Fill level indicator - show for any filament with valid remain data */}
{!isEmpty && tray.remain >= 0 && (
<div
className="absolute bottom-0 left-0 right-0 transition-all"
style={{
@@ -350,8 +406,8 @@ function AMSPanelContent({
}}
/>
)}
{/* Full color background for non-Bambu filaments or no remain data */}
{!isEmpty && (!tray.tray_uuid || tray.remain < 0) && (
{/* Full color background only when no valid remain data */}
{!isEmpty && tray.remain < 0 && (
<div
className="absolute inset-0"
style={{
@@ -371,9 +427,8 @@ function AMSPanelContent({
{/* Content overlay */}
<div className="relative w-full h-full flex flex-col items-center justify-end pb-[5px]">
<span
className={`text-[11px] font-semibold mb-1 ${
isLight ? 'text-gray-800' : 'text-white'
} ${isLight ? '' : 'drop-shadow-sm'}`}
className="text-[11px] font-semibold mb-1"
style={{ color: isLight ? '#000000' : '#ffffff' }}
>
{isEmpty ? '--' : tray.tray_type}
</span>
@@ -633,34 +688,32 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
// Distribute AMS units based on ams_extruder_map
// Each AMS unit's info field tells us which extruder it's connected to:
// extruder 0 = right nozzle, extruder 1 = left nozzle
// In Bambu slicer convention: extruder 0 = left nozzle, extruder 1 = right nozzle
const leftUnits = (() => {
if (!isDualNozzle) return amsUnits;
if (Object.keys(amsExtruderMap).length > 0) {
// Filter AMS units assigned to extruder 1 (left nozzle)
// Filter AMS units assigned to extruder 0 (left nozzle in slicer convention)
// JSON keys are strings, so convert unit.id to string
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
}
// Fallback: odd indices go to left (extruder 1)
return amsUnits.filter((_, i) => i % 2 === 1);
// Fallback: even indices go to left (extruder 0)
return amsUnits.filter((_, i) => i % 2 === 0);
})();
const rightUnits = (() => {
if (!isDualNozzle) return [];
if (Object.keys(amsExtruderMap).length > 0) {
// Filter AMS units assigned to extruder 0 (right nozzle)
// Filter AMS units assigned to extruder 1 (right nozzle in slicer convention)
// JSON keys are strings, so convert unit.id to string
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
}
// Fallback: even indices go to right (extruder 0)
return amsUnits.filter((_, i) => i % 2 === 0);
// Fallback: odd indices go to right (extruder 1)
return amsUnits.filter((_, i) => i % 2 === 1);
})();
const [leftAmsIndex, setLeftAmsIndex] = useState(0);
const [rightAmsIndex, setRightAmsIndex] = useState(0);
const [selectedTray, setSelectedTray] = useState<number | null>(null);
// Track if load has been triggered (to disable Load button until unload or slot change)
const [loadTriggered, setLoadTriggered] = useState(false);
// Modal states
const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null);
@@ -674,7 +727,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
// Track if we've done initial sync from tray_now
const initialSyncDone = useRef(false);
// Sync selectedTray and loadTriggered from status.tray_now on initial load
// Sync selectedTray from status.tray_now on initial load
// tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool
useEffect(() => {
if (initialSyncDone.current) return;
@@ -683,10 +736,10 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
if (trayNow !== undefined && trayNow !== null) {
initialSyncDone.current = true;
if (trayNow !== 255 && trayNow !== 254) {
// Valid AMS tray is loaded - select it and set loadTriggered
// Valid AMS tray is loaded - select it
// Note: We don't set loadTriggered here because the user may want to load a different slot
console.log(`[AMSSectionDual] Initializing from tray_now: ${trayNow}`);
setSelectedTray(trayNow);
setLoadTriggered(true);
} else {
// No filament loaded or external spool
console.log(`[AMSSectionDual] tray_now=${trayNow} (no AMS filament loaded)`);
@@ -699,8 +752,6 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
api.amsLoadFilament(printerId, trayId, extruderId),
onSuccess: (data, { trayId, extruderId }) => {
console.log(`[AMSSectionDual] Load filament success (tray ${trayId}, extruder ${extruderId}):`, data);
// Disable Load button after successful load
setLoadTriggered(true);
},
onError: (error, { trayId, extruderId }) => {
console.error(`[AMSSectionDual] Load filament error (tray ${trayId}, extruder ${extruderId}):`, error);
@@ -711,20 +762,14 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
mutationFn: () => api.amsUnloadFilament(printerId),
onSuccess: (data) => {
console.log(`[AMSSectionDual] Unload filament success:`, data);
// Re-enable Load button after unload
setLoadTriggered(false);
},
onError: (error) => {
console.error(`[AMSSectionDual] Unload filament error:`, error);
},
});
// Handle tray selection - also re-enables Load button when changing slot
// Handle tray selection
const handleTraySelect = (trayId: number | null) => {
if (trayId !== selectedTray) {
// Slot changed - re-enable Load button
setLoadTriggered(false);
}
setSelectedTray(trayId);
};
@@ -805,7 +850,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
].includes(currentStage);
// Auto-close card when operation completes
// Track when we transition from an active filament change stage back to -1
// Track when we transition from an active filament change stage back to idle
useEffect(() => {
const wasInFilamentChange = [
STAGE_HEATING_NOZZLE,
@@ -818,8 +863,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
// MQTT is now reporting a stage, clear user-triggered state
// Card will continue showing because isMqttFilamentChangeActive is true
setUserFilamentChange(null);
} else if (wasInFilamentChange && currentStage === -1) {
// Transition from active stage to idle - operation completed
} else if (wasInFilamentChange && !isMqttFilamentChangeActive) {
// Transition from active stage to idle (any non-filament-change stage, not just -1)
// Close the card by clearing user state
setUserFilamentChange(null);
}
@@ -867,8 +912,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
const currentLeftUnit = leftUnits[leftAmsIndex];
const currentRightUnit = rightUnits[rightAmsIndex];
if (extruderId === 1) {
// Left side (extruder 1)
if (extruderId === 0) {
// Left side (extruder 0) - leftUnits filters for amsExtruderMap === 0
// Only show colored wiring if the currently displayed AMS unit is the one with loaded filament
const isDisplayed = currentLeftUnit?.id === unit.id;
return {
@@ -878,7 +923,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
rightFilamentColor: null
};
} else {
// Right side (extruder 0)
// Right side (extruder 1) - rightUnits filters for amsExtruderMap === 1
const isDisplayed = currentRightUnit?.id === unit.id;
return {
leftActiveSlot: null,
@@ -957,9 +1002,9 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
<div className="flex items-center gap-2">
<button
onClick={handleUnload}
disabled={!isConnected || isPrinting || isLoading || !loadTriggered}
disabled={!isConnected || isPrinting || isLoading || trayNow === 255}
className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
!isConnected || isPrinting || isLoading || !loadTriggered
!isConnected || isPrinting || isLoading || trayNow === 255
? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
: 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
}`}
@@ -972,9 +1017,9 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
</button>
<button
onClick={handleLoad}
disabled={!isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered}
disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
!isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered
!isConnected || isPrinting || selectedTray === null || isLoading
? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
: 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
}`}
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.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.8131 0.00537678C18.4463 -0.150913 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00537678ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z" fill="#D0D0D0"/>
<path d="M8 46C12 48 24 48 28 46C26 50 22 52 18 52C14 52 10 50 8 46Z" fill="#1F8FEB"/>
</svg>

After

Width:  |  Height:  |  Size: 710 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z" fill="#1F8FEB"/>
<path d="M17.7948 0.00537678C18.4273 -0.150913 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0133678 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00537678ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z" fill="#D0D0D0"/>
</svg>

After

Width:  |  Height:  |  Size: 765 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="35" height="53" viewBox="0 0 35 53" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.3165 0.00379674C17.932 -0.149588 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148377 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.00379674ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z" fill="#D0D0D0"/>
<path d="M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z" fill="#1F8FEB"/>
</svg>

After

Width:  |  Height:  |  Size: 876 B

+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-lJk_Q6s0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKgM4K0H.css">
<script type="module" crossorigin src="/assets/index-BMICGIP2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BEhbt1yZ.css">
</head>
<body>
<div id="root"></div>