mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
AMS mapping fixes (7 bugs):
- backend/app/services/print_scheduler.py — hard nozzle filter + external spool extruder - backend/app/api/routes/printers.py — ams_extruder_map race condition - backend/app/utils/threemf_tools.py — group_id priority + dual-nozzle detection - frontend/src/hooks/useFilamentMapping.ts — external spool extruder - frontend/src/pages/PrintersPage.tsx — AMS-HT globalTrayId - frontend/src/components/PrintModal/FilamentMapping.tsx — dropdown nozzle filter Usage tracking fix: - backend/app/services/bambu_mqtt.py — capture last valid progress/layer - backend/app/services/usage_tracker.py — use captured values on cancel UI fix: - frontend/src/components/spool-form/ColorSection.tsx — color tooltip z-index Tests: - backend/tests/unit/test_scheduler_ams_mapping.py — 63 tests - frontend/src/__tests__/hooks/useFilamentMapping.test.ts — 43 tests Static assets: - static/ — rebuilt frontend bundles
This commit is contained in:
@@ -402,8 +402,9 @@ async def get_printer_status(
|
||||
|
||||
# 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", {})
|
||||
# Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
|
||||
# where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
|
||||
ams_extruder_map = state.ams_extruder_map or {}
|
||||
logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
|
||||
|
||||
# tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
|
||||
|
||||
@@ -279,6 +279,8 @@ class BambuMQTTClient:
|
||||
self._was_running: bool = False # Track if we've seen RUNNING state for current print
|
||||
self._completion_triggered: bool = False # Prevent duplicate completion triggers
|
||||
self._timelapse_during_print: bool = False # Track if timelapse was active during this print
|
||||
self._last_valid_progress: float = 0.0 # Last non-zero progress (firmware resets on cancel)
|
||||
self._last_valid_layer_num: int = 0 # Last non-zero layer (firmware resets on cancel)
|
||||
self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
|
||||
self._logging_enabled: bool = False
|
||||
self._last_message_time: float = 0.0 # Track when we last received a message
|
||||
@@ -1211,6 +1213,9 @@ class BambuMQTTClient:
|
||||
if "subtask_id" in data:
|
||||
self.state.subtask_id = data["subtask_id"]
|
||||
if "mc_percent" in data:
|
||||
# Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
|
||||
if self.state.progress > 0:
|
||||
self._last_valid_progress = self.state.progress
|
||||
self.state.progress = float(data["mc_percent"])
|
||||
if "mc_remaining_time" in data:
|
||||
self.state.remaining_time = int(data["mc_remaining_time"])
|
||||
@@ -1225,6 +1230,9 @@ class BambuMQTTClient:
|
||||
if "layer_num" in data:
|
||||
new_layer = int(data["layer_num"])
|
||||
old_layer = self.state.layer_num
|
||||
# Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
|
||||
if old_layer > 0:
|
||||
self._last_valid_layer_num = old_layer
|
||||
self.state.layer_num = new_layer
|
||||
# Trigger layer change callback if layer increased
|
||||
if new_layer > old_layer and self.on_layer_change:
|
||||
@@ -1984,6 +1992,9 @@ class BambuMQTTClient:
|
||||
# Reset completion tracking for new print
|
||||
self._was_running = True
|
||||
self._completion_triggered = False
|
||||
# Reset last valid progress/layer for usage tracking
|
||||
self._last_valid_progress = 0.0
|
||||
self._last_valid_layer_num = 0
|
||||
# Initialize timelapse tracking based on current state
|
||||
# NOTE: xcam data is parsed BEFORE this code runs in _process_message,
|
||||
# so self.state.timelapse may already be set from this message.
|
||||
@@ -2067,6 +2078,9 @@ class BambuMQTTClient:
|
||||
"timelapse_was_active": timelapse_was_active,
|
||||
"hms_errors": hms_errors_data,
|
||||
"ams_mapping": self._captured_ams_mapping,
|
||||
# Last valid progress/layer before firmware reset (for partial usage tracking)
|
||||
"last_progress": self._last_valid_progress,
|
||||
"last_layer_num": self._last_valid_layer_num,
|
||||
}
|
||||
)
|
||||
self._captured_ams_mapping = None
|
||||
|
||||
@@ -558,7 +558,7 @@ class PrintScheduler:
|
||||
"is_ht": False,
|
||||
"is_external": True,
|
||||
"global_tray_id": tray_id,
|
||||
"extruder_id": (tray_id - 254) if ams_extruder_map else None,
|
||||
"extruder_id": (255 - tray_id) if ams_extruder_map else None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -634,12 +634,12 @@ class PrintScheduler:
|
||||
# Get available trays (not already used)
|
||||
available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
|
||||
|
||||
# Nozzle-aware filtering: restrict to trays on the correct nozzle
|
||||
# Nozzle-aware filtering: restrict to trays on the correct nozzle.
|
||||
# Hard filter — cross-nozzle assignment causes print failures
|
||||
# ("position of left hotend is abnormal"), so never fall back.
|
||||
req_nozzle_id = req.get("nozzle_id")
|
||||
if req_nozzle_id is not None:
|
||||
nozzle_filtered = [f for f in available if f.get("extruder_id") == req_nozzle_id]
|
||||
if nozzle_filtered:
|
||||
available = nozzle_filtered
|
||||
available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
|
||||
|
||||
# Check if tray_info_idx is unique among available trays
|
||||
if req_tray_info_idx:
|
||||
|
||||
@@ -167,6 +167,8 @@ async def on_print_complete(
|
||||
db,
|
||||
ams_mapping=ams_mapping,
|
||||
tray_now_at_start=session.tray_now_at_start if session else -1,
|
||||
last_progress=data.get("last_progress", 0.0),
|
||||
last_layer_num=data.get("last_layer_num", 0),
|
||||
)
|
||||
results.extend(threemf_results)
|
||||
|
||||
@@ -276,6 +278,8 @@ async def _track_from_3mf(
|
||||
db: AsyncSession,
|
||||
ams_mapping: list[int] | None = None,
|
||||
tray_now_at_start: int = -1,
|
||||
last_progress: float = 0.0,
|
||||
last_layer_num: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Track usage from 3MF per-filament slicer data (primary path).
|
||||
|
||||
@@ -374,6 +378,10 @@ async def _track_from_3mf(
|
||||
else:
|
||||
state = printer_manager.get_status(printer_id)
|
||||
progress = state.progress if state else 0
|
||||
# Firmware resets progress to 0 on cancel — use last valid progress captured during print
|
||||
if progress <= 0 and last_progress > 0:
|
||||
progress = last_progress
|
||||
logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
|
||||
scale = max(0.0, min(progress / 100.0, 1.0))
|
||||
|
||||
# Per-layer gcode accuracy for partial prints
|
||||
@@ -381,6 +389,10 @@ async def _track_from_3mf(
|
||||
if status != "completed":
|
||||
state = printer_manager.get_status(printer_id)
|
||||
current_layer = state.layer_num if state else 0
|
||||
# Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
|
||||
if current_layer <= 0 and last_layer_num > 0:
|
||||
current_layer = last_layer_num
|
||||
logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
|
||||
if current_layer > 0:
|
||||
try:
|
||||
from backend.app.utils.threemf_tools import (
|
||||
|
||||
@@ -265,15 +265,18 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
|
||||
|
||||
|
||||
def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
|
||||
"""Extract per-slot nozzle/extruder mapping from a 3MF file's project settings.
|
||||
"""Extract per-slot nozzle/extruder mapping from a 3MF file.
|
||||
|
||||
On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
|
||||
specific nozzle. This reads the slicer's nozzle assignment from
|
||||
Metadata/project_settings.config.
|
||||
specific nozzle. The slicer may override user preferences when using "Auto For
|
||||
Flush" mode, so the actual assignment comes from slice_info.config group_id
|
||||
attributes, not from the user's filament_nozzle_map preference.
|
||||
|
||||
Translation chain:
|
||||
filament_nozzle_map[slot_id - 1] -> slicer extruder index
|
||||
physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left)
|
||||
Priority:
|
||||
1. group_id on <filament> elements in slice_info.config (actual assignment)
|
||||
2. filament_nozzle_map in project_settings.config (user preference fallback)
|
||||
|
||||
Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
|
||||
|
||||
Args:
|
||||
zf: An open ZipFile of the 3MF archive
|
||||
@@ -289,33 +292,48 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
|
||||
content = zf.read("Metadata/project_settings.config").decode()
|
||||
data = json.loads(content)
|
||||
|
||||
filament_nozzle_map = data.get("filament_nozzle_map")
|
||||
physical_extruder_map = data.get("physical_extruder_map")
|
||||
if not physical_extruder_map or len(physical_extruder_map) <= 1:
|
||||
return None # Single-nozzle printer
|
||||
|
||||
if not filament_nozzle_map or not physical_extruder_map:
|
||||
# Priority 1: Use group_id from slice_info filament elements.
|
||||
# This reflects the actual slicer assignment (respects "Auto For Flush").
|
||||
nozzle_mapping: dict[int, int] = {}
|
||||
if "Metadata/slice_info.config" in zf.namelist():
|
||||
si_content = zf.read("Metadata/slice_info.config").decode()
|
||||
si_root = ET.fromstring(si_content)
|
||||
for filament_elem in si_root.findall(".//filament"):
|
||||
group_id_str = filament_elem.get("group_id")
|
||||
filament_id_str = filament_elem.get("id")
|
||||
if group_id_str is not None and filament_id_str:
|
||||
try:
|
||||
group_id = int(group_id_str)
|
||||
slot_id = int(filament_id_str)
|
||||
if group_id < len(physical_extruder_map):
|
||||
nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])
|
||||
except (ValueError, TypeError, IndexError):
|
||||
pass
|
||||
|
||||
if nozzle_mapping:
|
||||
return nozzle_mapping
|
||||
|
||||
# Priority 2: Fall back to filament_nozzle_map (user preference).
|
||||
# This is correct when the user manually assigned nozzles, but may be
|
||||
# wrong when the slicer overrides via "Auto For Flush".
|
||||
filament_nozzle_map = data.get("filament_nozzle_map")
|
||||
if not filament_nozzle_map:
|
||||
return None
|
||||
|
||||
# Build slot_id (1-based) -> extruder_id mapping
|
||||
nozzle_mapping: dict[int, int] = {}
|
||||
for i, slicer_ext_str in enumerate(filament_nozzle_map):
|
||||
slot_id = i + 1
|
||||
try:
|
||||
slicer_ext = int(slicer_ext_str)
|
||||
if slicer_ext < len(physical_extruder_map):
|
||||
extruder_id = int(physical_extruder_map[slicer_ext])
|
||||
nozzle_mapping[slot_id] = extruder_id
|
||||
nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])
|
||||
except (ValueError, TypeError, IndexError):
|
||||
pass # Skip slots with unparseable nozzle mapping
|
||||
pass
|
||||
|
||||
if not nozzle_mapping:
|
||||
return None
|
||||
|
||||
# If all slots map to the same extruder, this is a single-nozzle printer
|
||||
unique_extruders = set(nozzle_mapping.values())
|
||||
if len(unique_extruders) <= 1:
|
||||
return None
|
||||
|
||||
return nozzle_mapping
|
||||
return nozzle_mapping if nozzle_mapping else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -474,12 +474,17 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
|
||||
assert result[0]["is_external"] is True
|
||||
|
||||
|
||||
def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
|
||||
def _make_3mf_zip(
|
||||
project_settings: dict | None = None,
|
||||
slice_info_xml: str | None = None,
|
||||
) -> zipfile.ZipFile:
|
||||
"""Create an in-memory ZipFile mimicking a 3MF with project_settings.config."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
if project_settings is not None:
|
||||
zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
|
||||
if slice_info_xml is not None:
|
||||
zf.writestr("Metadata/slice_info.config", slice_info_xml)
|
||||
buf.seek(0)
|
||||
return zipfile.ZipFile(buf, "r")
|
||||
|
||||
@@ -487,8 +492,55 @@ def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
|
||||
class TestExtractNozzleMappingFrom3mf:
|
||||
"""Test the extract_nozzle_mapping_from_3mf utility."""
|
||||
|
||||
def test_dual_nozzle_mapping(self):
|
||||
"""Should return slot->extruder mapping for dual-nozzle files."""
|
||||
def test_group_id_priority_over_filament_nozzle_map(self):
|
||||
"""group_id from slice_info should override filament_nozzle_map from project_settings.
|
||||
|
||||
Real-world scenario: "Auto For Flush" mode sets filament_nozzle_map all to 0
|
||||
(user preference) but the actual assignment in slice_info has different group_ids.
|
||||
"""
|
||||
# filament_nozzle_map says all on slicer ext 0 → MQTT ext 1 (LEFT)
|
||||
# But slice_info group_id says slot 6 → group 0 (LEFT), slot 12 → group 1 (RIGHT)
|
||||
slice_info = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<filament id="6" type="PLA" color="#56B7E6" used_g="1.84" group_id="0"/>
|
||||
<filament id="12" type="PLA" color="#B39B84" used_g="1.76" group_id="1"/>
|
||||
</plate>
|
||||
</config>"""
|
||||
zf = _make_3mf_zip(
|
||||
{
|
||||
"filament_nozzle_map": ["0"] * 12,
|
||||
"physical_extruder_map": ["1", "0"],
|
||||
},
|
||||
slice_info_xml=slice_info,
|
||||
)
|
||||
result = extract_nozzle_mapping_from_3mf(zf)
|
||||
# group_id 0 → physical_extruder_map[0] = 1 (LEFT)
|
||||
# group_id 1 → physical_extruder_map[1] = 0 (RIGHT)
|
||||
assert result == {6: 1, 12: 0}
|
||||
zf.close()
|
||||
|
||||
def test_fallback_to_filament_nozzle_map_without_group_id(self):
|
||||
"""Should fall back to filament_nozzle_map when slice_info has no group_id."""
|
||||
slice_info = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<filament id="1" type="PLA" color="#FF0000" used_g="5.0"/>
|
||||
</plate>
|
||||
</config>"""
|
||||
zf = _make_3mf_zip(
|
||||
{
|
||||
"filament_nozzle_map": ["0", "1", "0"],
|
||||
"physical_extruder_map": ["0", "1"],
|
||||
},
|
||||
slice_info_xml=slice_info,
|
||||
)
|
||||
result = extract_nozzle_mapping_from_3mf(zf)
|
||||
assert result == {1: 0, 2: 1, 3: 0}
|
||||
zf.close()
|
||||
|
||||
def test_fallback_to_filament_nozzle_map_without_slice_info(self):
|
||||
"""Should fall back to filament_nozzle_map when no slice_info.config exists."""
|
||||
zf = _make_3mf_zip(
|
||||
{
|
||||
"filament_nozzle_map": ["0", "1", "0"],
|
||||
@@ -500,7 +552,7 @@ class TestExtractNozzleMappingFrom3mf:
|
||||
zf.close()
|
||||
|
||||
def test_single_nozzle_returns_none(self):
|
||||
"""All slots on same extruder should return None (single-nozzle)."""
|
||||
"""Single physical_extruder_map entry should return None (single-nozzle)."""
|
||||
zf = _make_3mf_zip(
|
||||
{
|
||||
"filament_nozzle_map": ["0", "0", "0"],
|
||||
@@ -519,7 +571,7 @@ class TestExtractNozzleMappingFrom3mf:
|
||||
zf.close()
|
||||
|
||||
def test_missing_fields_returns_none(self):
|
||||
"""Missing filament_nozzle_map or physical_extruder_map should return None."""
|
||||
"""Missing physical_extruder_map should return None."""
|
||||
zf = _make_3mf_zip({"some_other_key": "value"})
|
||||
result = extract_nozzle_mapping_from_3mf(zf)
|
||||
assert result is None
|
||||
@@ -562,8 +614,8 @@ class TestNozzleAwareMapping:
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [0, 4]
|
||||
|
||||
def test_nozzle_fallback_when_no_match(self, scheduler):
|
||||
"""Should fall back to unfiltered list when nozzle-filtered list is empty."""
|
||||
def test_nozzle_hard_filter_no_fallback(self, scheduler):
|
||||
"""Hard filter: no fallback to wrong nozzle when target nozzle has no trays."""
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0}, # Right nozzle
|
||||
]
|
||||
@@ -571,9 +623,9 @@ class TestNozzleAwareMapping:
|
||||
# Only a tray on the left nozzle, none on right
|
||||
{"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},
|
||||
]
|
||||
# No trays on extruder 0, so fallback to unfiltered -> should still match
|
||||
# No trays on extruder 0 — hard filter returns -1, no cross-nozzle fallback
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [4]
|
||||
assert result == [-1]
|
||||
|
||||
def test_no_nozzle_id_skips_filtering(self, scheduler):
|
||||
"""When nozzle_id is None, no nozzle filtering should be applied."""
|
||||
@@ -620,7 +672,7 @@ class TestNozzleAwareMapping:
|
||||
assert result[0]["extruder_id"] is None
|
||||
|
||||
def test_external_spool_extruder_id(self, scheduler):
|
||||
"""External spool should have extruder_id=0 when ams_extruder_map exists."""
|
||||
"""External spool 254 (Ext-L) should have extruder_id=1 (LEFT) when ams_extruder_map exists."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = {
|
||||
@@ -630,7 +682,8 @@ class TestNozzleAwareMapping:
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
assert len(result) == 1
|
||||
assert result[0]["extruder_id"] == 0
|
||||
# Default vt_tray id=254 → Ext-L → LEFT nozzle (extruder 1)
|
||||
assert result[0]["extruder_id"] == 1
|
||||
assert result[0]["is_external"] is True
|
||||
|
||||
def test_external_spool_no_extruder_map(self, scheduler):
|
||||
@@ -655,3 +708,302 @@ class TestNozzleAwareMapping:
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [0, 4]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MODEL-SPECIFIC TESTS: Real data from actual printers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _h2d_raw_data():
|
||||
"""H2D real data fixture (from live API response 2026-02-18).
|
||||
|
||||
Configuration:
|
||||
LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
|
||||
RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
|
||||
External: 254 (Ext-L, LEFT), 255 (Ext-R, RIGHT, empty)
|
||||
|
||||
ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
|
||||
"""
|
||||
return {
|
||||
"ams": [
|
||||
{
|
||||
"id": 0,
|
||||
"tray": [
|
||||
{"id": 0, "tray_type": "PETG", "tray_color": "FFFFFFFF", "tray_info_idx": "GFG02"},
|
||||
{"id": 1, "tray_type": "PLA", "tray_color": "C8C8C8FF", "tray_info_idx": "GFA06"},
|
||||
{"id": 2, "tray_type": "PETG", "tray_color": "875718FF", "tray_info_idx": "GFG02"},
|
||||
{"id": 3, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "GFA00"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"tray": [
|
||||
{"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
|
||||
{"id": 1, "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG02"},
|
||||
{"id": 2, "tray_type": "PLA", "tray_color": "5F6367FF", "tray_info_idx": "GFA06"},
|
||||
{"id": 3, "tray_type": "PLA", "tray_color": "B39B84FF", "tray_info_idx": "GFA02"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 128,
|
||||
"tray": [{"id": 0}], # AMS-HT, empty
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"tray": [
|
||||
{"id": 0, "tray_type": "PLA-S", "tray_color": "FFFFFFFF", "tray_info_idx": "P8aa1726"},
|
||||
{"id": 1, "tray_type": "PLA", "tray_color": "56B7E6FF", "tray_info_idx": "PFUS9924"},
|
||||
{"id": 2, "tray_type": "PETG", "tray_color": "6EE53CFF", "tray_info_idx": "GFG02"},
|
||||
{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "PFUS9ac9"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"vt_tray": [
|
||||
{"id": 254, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "P4d64437"},
|
||||
{"id": 255, "tray_type": "", "tray_color": "00000000"}, # empty
|
||||
],
|
||||
"ams_extruder_map": {"0": 1, "1": 0, "2": 1, "128": 0},
|
||||
}
|
||||
|
||||
|
||||
def _x1c_raw_data():
|
||||
"""X1C real data fixture (from live API response 2026-02-18).
|
||||
|
||||
Configuration:
|
||||
Single nozzle (extruder 0): AMS 0 (4-slot, all empty), AMS 1 (4-slot, 3 loaded)
|
||||
External: 254 (single, empty)
|
||||
|
||||
ams_extruder_map: {"0": 0, "1": 0} ← NOT empty, all on extruder 0
|
||||
"""
|
||||
return {
|
||||
"ams": [
|
||||
{
|
||||
"id": 0,
|
||||
"tray": [
|
||||
{"id": 0}, # empty
|
||||
{"id": 1}, # empty
|
||||
{"id": 2}, # empty
|
||||
{"id": 3}, # empty
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"tray": [
|
||||
{"id": 0}, # empty
|
||||
{"id": 1, "tray_type": "PLA", "tray_color": "EBCFA6FF", "tray_info_idx": "PFUS22b2"},
|
||||
{"id": 2, "tray_type": "PLA", "tray_color": "FCECD6FF", "tray_info_idx": "P4d64437"},
|
||||
{"id": 3, "tray_type": "PLA", "tray_color": "0066FFFF", "tray_info_idx": "P4d64437"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"vt_tray": [
|
||||
{"id": 254, "tray_type": "", "tray_color": "00000000"}, # empty
|
||||
],
|
||||
"ams_extruder_map": {"0": 0, "1": 0},
|
||||
}
|
||||
|
||||
|
||||
class TestH2DModel:
|
||||
"""H2D-specific tests with real printer data (dual nozzle, AMS-HT)."""
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(self):
|
||||
return PrintScheduler()
|
||||
|
||||
def test_build_loaded_filaments_h2d(self, scheduler):
|
||||
"""H2D: correct extruder_id, global_tray_id, AMS-HT handling."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _h2d_raw_data()
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
|
||||
# Should have 13 loaded filaments (4 + 4 + 0 + 4 + 1 external)
|
||||
assert len(result) == 13
|
||||
|
||||
# AMS 0 trays → extruder 1 (LEFT)
|
||||
ams0 = [f for f in result if f["ams_id"] == 0]
|
||||
assert len(ams0) == 4
|
||||
assert all(f["extruder_id"] == 1 for f in ams0)
|
||||
assert [f["global_tray_id"] for f in ams0] == [0, 1, 2, 3]
|
||||
|
||||
# AMS 1 trays → extruder 0 (RIGHT)
|
||||
ams1 = [f for f in result if f["ams_id"] == 1]
|
||||
assert len(ams1) == 4
|
||||
assert all(f["extruder_id"] == 0 for f in ams1)
|
||||
assert [f["global_tray_id"] for f in ams1] == [4, 5, 6, 7]
|
||||
|
||||
# AMS-HT 128 → empty, should not appear
|
||||
ams_ht = [f for f in result if f["ams_id"] == 128]
|
||||
assert len(ams_ht) == 0
|
||||
|
||||
# AMS 2 trays → extruder 1 (LEFT)
|
||||
ams2 = [f for f in result if f["ams_id"] == 2]
|
||||
assert len(ams2) == 4
|
||||
assert all(f["extruder_id"] == 1 for f in ams2)
|
||||
assert [f["global_tray_id"] for f in ams2] == [8, 9, 10, 11]
|
||||
|
||||
def test_external_spool_extruder_h2d(self, scheduler):
|
||||
"""H2D: Ext-L (254) = LEFT (extruder 1), Ext-R (255) = RIGHT (extruder 0)."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _h2d_raw_data()
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
ext = [f for f in result if f["is_external"]]
|
||||
assert len(ext) == 1 # Only 254 has filament
|
||||
assert ext[0]["global_tray_id"] == 254
|
||||
# Ext-L (254) should be LEFT nozzle (extruder 1)
|
||||
assert ext[0]["extruder_id"] == 1
|
||||
|
||||
def test_match_left_nozzle_only(self, scheduler):
|
||||
"""H2D: left-nozzle requirement only matches left-nozzle AMS."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _h2d_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 1}, # LEFT
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
# Black PLA on LEFT: AMS 0 T4 (global 3)
|
||||
assert result == [3]
|
||||
|
||||
def test_match_right_nozzle_only(self, scheduler):
|
||||
"""H2D: right-nozzle requirement only matches right-nozzle AMS."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _h2d_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 0}, # RIGHT
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
# White PLA on RIGHT: AMS 1 T1 (global 4)
|
||||
assert result == [4]
|
||||
|
||||
def test_reject_cross_nozzle(self, scheduler):
|
||||
"""H2D: hard filter rejects cross-nozzle assignment."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _h2d_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
# PLA-S only exists on AMS 2 T1 (LEFT), require on RIGHT
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA-S", "color": "#FFFFFF", "nozzle_id": 0},
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [-1] # No fallback to wrong nozzle
|
||||
|
||||
def test_dual_nozzle_multi_filament(self, scheduler):
|
||||
"""H2D: multi-filament print maps to correct nozzles."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _h2d_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PETG", "color": "#FFFFFF", "nozzle_id": 1, "tray_info_idx": "GFG02"},
|
||||
{"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 0, "tray_info_idx": "GFA00"},
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
# PETG white on LEFT: AMS 0 T1 (global 0)
|
||||
# PLA white on RIGHT: AMS 1 T1 (global 4)
|
||||
assert result == [0, 4]
|
||||
|
||||
def test_external_spool_matches_on_correct_nozzle(self, scheduler):
|
||||
"""H2D: external spool on left nozzle matches left-nozzle requirement."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _h2d_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 1, "tray_info_idx": "P4d64437"},
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [254] # External spool on left nozzle
|
||||
|
||||
|
||||
class TestX1CModel:
|
||||
"""X1C-specific tests with real printer data (single nozzle, 2x regular AMS)."""
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(self):
|
||||
return PrintScheduler()
|
||||
|
||||
def test_build_loaded_filaments_x1c(self, scheduler):
|
||||
"""X1C: all filaments on extruder 0, correct global_tray_id."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _x1c_raw_data()
|
||||
|
||||
result = scheduler._build_loaded_filaments(MockStatus())
|
||||
|
||||
# Only 3 loaded (AMS 1 trays 1-3)
|
||||
assert len(result) == 3
|
||||
# All on extruder 0
|
||||
assert all(f["extruder_id"] == 0 for f in result)
|
||||
# Correct global tray IDs
|
||||
assert [f["global_tray_id"] for f in result] == [5, 6, 7]
|
||||
|
||||
def test_single_nozzle_no_filtering(self, scheduler):
|
||||
"""X1C: single-nozzle 3MF has no nozzle_id, all trays available."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _x1c_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#0066FF"}, # No nozzle_id
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
# Blue PLA → AMS 1 T4 (global 7)
|
||||
assert result == [7]
|
||||
|
||||
def test_tray_info_idx_matching_x1c(self, scheduler):
|
||||
"""X1C: tray_info_idx matching works across AMS units."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _x1c_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#EBCFA6", "tray_info_idx": "PFUS22b2"},
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
# Unique tray_info_idx → AMS 1 T2 (global 5)
|
||||
assert result == [5]
|
||||
|
||||
def test_non_unique_tray_info_idx_color_match_x1c(self, scheduler):
|
||||
"""X1C: non-unique tray_info_idx falls back to color matching."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _x1c_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
# P4d64437 appears in AMS 1 T3 and T4
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#FCECD6", "tray_info_idx": "P4d64437"},
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
# Should pick AMS 1 T3 (global 6, color FCECD6) over T4 (0066FF)
|
||||
assert result == [6]
|
||||
|
||||
def test_multi_filament_x1c(self, scheduler):
|
||||
"""X1C: multi-filament print matches freely across AMS units."""
|
||||
|
||||
class MockStatus:
|
||||
raw_data = _x1c_raw_data()
|
||||
|
||||
loaded = scheduler._build_loaded_filaments(MockStatus())
|
||||
required = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#EBCFA6"},
|
||||
{"slot_id": 2, "type": "PLA", "color": "#0066FF"},
|
||||
]
|
||||
result = scheduler._match_filaments_to_slots(required, loaded)
|
||||
assert result == [5, 7]
|
||||
|
||||
@@ -430,8 +430,9 @@ describe('computeAmsMapping - nozzle filtering', () => {
|
||||
expect(result).toEqual([4]); // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
|
||||
});
|
||||
|
||||
it('falls back to all trays when target nozzle has no trays at all', () => {
|
||||
it('returns -1 when target nozzle has no trays (hard filter)', () => {
|
||||
// Requires nozzle_id=1 (left), but no AMS units are on left nozzle
|
||||
// Hard filter: cross-nozzle assignment causes "position of left hotend is abnormal"
|
||||
const reqs = {
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
|
||||
@@ -447,7 +448,7 @@ describe('computeAmsMapping - nozzle filtering', () => {
|
||||
|
||||
const result = computeAmsMapping(reqs, status);
|
||||
|
||||
expect(result).toEqual([0]); // Falls back to unfiltered (right nozzle PLA)
|
||||
expect(result).toEqual([-1]); // Hard filter: no fallback to wrong nozzle
|
||||
});
|
||||
|
||||
it('stays restricted when target nozzle has trays but wrong type', () => {
|
||||
@@ -526,3 +527,306 @@ describe('computeAmsMapping - nozzle filtering', () => {
|
||||
expect(result).toEqual([0, 4]); // Left gets AMS0-T0, Right gets AMS1-T0
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MODEL-SPECIFIC TESTS: Real data from actual printers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* H2D real data fixture (from live API response 2026-02-18).
|
||||
*
|
||||
* Configuration:
|
||||
* LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
|
||||
* RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
|
||||
* External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle)
|
||||
*
|
||||
* ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
|
||||
*/
|
||||
function createH2DStatus(): PrinterStatus {
|
||||
const status = createPrinterStatus(
|
||||
[
|
||||
{
|
||||
id: 0, // LEFT nozzle (extruder 1)
|
||||
humidity: 24,
|
||||
temp: 21.4,
|
||||
tray: [
|
||||
{ id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
|
||||
{ id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
|
||||
{ id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
|
||||
{ id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1, // RIGHT nozzle (extruder 0)
|
||||
humidity: 25,
|
||||
temp: 21.7,
|
||||
tray: [
|
||||
{ id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
|
||||
{ id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
|
||||
{ id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
|
||||
{ id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty
|
||||
humidity: 48,
|
||||
temp: 21.4,
|
||||
tray: [
|
||||
{ id: 0 }, // empty tray
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, // LEFT nozzle (extruder 1)
|
||||
humidity: 18,
|
||||
temp: 24.0,
|
||||
tray: [
|
||||
{ id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' },
|
||||
{ id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' },
|
||||
{ id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
|
||||
{ id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded)
|
||||
{ id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty)
|
||||
]
|
||||
);
|
||||
(status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 };
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* X1C real data fixture (from live API response 2026-02-18).
|
||||
*
|
||||
* Configuration:
|
||||
* Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot)
|
||||
* External: 254 (single)
|
||||
*
|
||||
* ams_extruder_map: {"0": 0, "1": 0} ← NOT empty, all on extruder 0
|
||||
*/
|
||||
function createX1CStatus(): PrinterStatus {
|
||||
const status = createPrinterStatus(
|
||||
[
|
||||
{
|
||||
id: 0,
|
||||
humidity: 23,
|
||||
temp: 26.1,
|
||||
tray: [
|
||||
{ id: 0 }, // empty (has tray_color but no tray_type)
|
||||
{ id: 1 }, // empty
|
||||
{ id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type)
|
||||
{ id: 3 }, // empty
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
humidity: 20,
|
||||
temp: 25.9,
|
||||
tray: [
|
||||
{ id: 0 }, // empty
|
||||
{ id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' },
|
||||
{ id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' },
|
||||
{ id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: 254, tray_type: '', tray_color: '00000000' }, // empty
|
||||
]
|
||||
);
|
||||
(status as any).ams_extruder_map = { '0': 0, '1': 0 };
|
||||
return status;
|
||||
}
|
||||
|
||||
describe('H2D model tests (dual nozzle, real data)', () => {
|
||||
describe('buildLoadedFilaments', () => {
|
||||
it('assigns correct extruderId to all AMS units', () => {
|
||||
const result = buildLoadedFilaments(createH2DStatus());
|
||||
|
||||
// AMS 0 trays → extruder 1 (LEFT)
|
||||
const ams0 = result.filter((f) => f.amsId === 0);
|
||||
expect(ams0).toHaveLength(4);
|
||||
ams0.forEach((f) => expect(f.extruderId).toBe(1));
|
||||
|
||||
// AMS 1 trays → extruder 0 (RIGHT)
|
||||
const ams1 = result.filter((f) => f.amsId === 1);
|
||||
expect(ams1).toHaveLength(4);
|
||||
ams1.forEach((f) => expect(f.extruderId).toBe(0));
|
||||
|
||||
// AMS 2 trays → extruder 1 (LEFT)
|
||||
const ams2 = result.filter((f) => f.amsId === 2);
|
||||
expect(ams2).toHaveLength(4);
|
||||
ams2.forEach((f) => expect(f.extruderId).toBe(1));
|
||||
});
|
||||
|
||||
it('computes correct globalTrayId for all AMS types', () => {
|
||||
const result = buildLoadedFilaments(createH2DStatus());
|
||||
|
||||
// Regular AMS: amsId * 4 + trayId
|
||||
expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0);
|
||||
expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3);
|
||||
expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4);
|
||||
expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
|
||||
expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8);
|
||||
expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11);
|
||||
});
|
||||
|
||||
it('skips empty AMS-HT tray (no tray_type)', () => {
|
||||
const result = buildLoadedFilaments(createH2DStatus());
|
||||
// AMS-HT 128 is empty in real data — should be skipped
|
||||
const ht = result.filter((f) => f.amsId === 128);
|
||||
expect(ht).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('includes loaded external spool with correct extruder', () => {
|
||||
const result = buildLoadedFilaments(createH2DStatus());
|
||||
const ext = result.filter((f) => f.isExternal);
|
||||
// Only Ext-L (254) has filament, Ext-R (255) is empty
|
||||
expect(ext).toHaveLength(1);
|
||||
expect(ext[0].globalTrayId).toBe(254);
|
||||
expect(ext[0].type).toBe('PLA');
|
||||
// Ext-L (254) should be LEFT nozzle (extruder 1)
|
||||
expect(ext[0].extruderId).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 13 loaded filaments total (12 AMS + 1 external)', () => {
|
||||
const result = buildLoadedFilaments(createH2DStatus());
|
||||
// AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1
|
||||
expect(result).toHaveLength(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAmsMapping', () => {
|
||||
it('matches left-nozzle filament to left-nozzle AMS only', () => {
|
||||
const reqs = {
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createH2DStatus());
|
||||
// Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left
|
||||
expect(result).toEqual([3]);
|
||||
});
|
||||
|
||||
it('matches right-nozzle filament to right-nozzle AMS only', () => {
|
||||
const reqs = {
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createH2DStatus());
|
||||
// White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right
|
||||
expect(result).toEqual([4]);
|
||||
});
|
||||
|
||||
it('rejects cross-nozzle assignment (right requires type only on left)', () => {
|
||||
const reqs = {
|
||||
filaments: [
|
||||
// PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle
|
||||
{ slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createH2DStatus());
|
||||
expect(result).toEqual([-1]); // No fallback to wrong nozzle
|
||||
});
|
||||
|
||||
it('maps dual-nozzle multi-filament print correctly', () => {
|
||||
const reqs = {
|
||||
filaments: [
|
||||
// Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0)
|
||||
{ slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' },
|
||||
// Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4)
|
||||
{ slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createH2DStatus());
|
||||
expect(result).toEqual([0, 4]);
|
||||
});
|
||||
|
||||
it('matches external spool on correct nozzle', () => {
|
||||
const reqs = {
|
||||
filaments: [
|
||||
// Ext-L has black PLA loaded, on LEFT nozzle (extruder 1)
|
||||
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createH2DStatus());
|
||||
expect(result).toEqual([254]); // External spool on left nozzle
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('X1C model tests (single nozzle, real data)', () => {
|
||||
describe('buildLoadedFilaments', () => {
|
||||
it('assigns all filaments to extruder 0', () => {
|
||||
const result = buildLoadedFilaments(createX1CStatus());
|
||||
result.forEach((f) => expect(f.extruderId).toBe(0));
|
||||
});
|
||||
|
||||
it('computes correct globalTrayId for regular AMS', () => {
|
||||
const result = buildLoadedFilaments(createX1CStatus());
|
||||
// AMS 1 T2 (tray id 1) → globalTrayId 5
|
||||
expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5);
|
||||
// AMS 1 T3 (tray id 2) → globalTrayId 6
|
||||
expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6);
|
||||
// AMS 1 T4 (tray id 3) → globalTrayId 7
|
||||
expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
|
||||
});
|
||||
|
||||
it('returns only loaded trays (3 from AMS 1)', () => {
|
||||
const result = buildLoadedFilaments(createX1CStatus());
|
||||
// AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAmsMapping', () => {
|
||||
it('matches single-nozzle file without nozzle filtering', () => {
|
||||
const reqs = {
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createX1CStatus());
|
||||
// Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF)
|
||||
expect(result).toEqual([7]);
|
||||
});
|
||||
|
||||
it('matches by tray_info_idx across AMS units', () => {
|
||||
const reqs = {
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createX1CStatus());
|
||||
// PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5)
|
||||
expect(result).toEqual([5]);
|
||||
});
|
||||
|
||||
it('handles non-unique tray_info_idx with color matching', () => {
|
||||
// P4d64437 appears in both AMS 1 T3 and T4
|
||||
const reqs = {
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createX1CStatus());
|
||||
// Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF)
|
||||
expect(result).toEqual([6]);
|
||||
});
|
||||
|
||||
it('does not cross-nozzle filter for single-nozzle printer', () => {
|
||||
// Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id
|
||||
const reqs = {
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 },
|
||||
{ slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 },
|
||||
],
|
||||
};
|
||||
const result = computeAmsMapping(reqs, createX1CStatus());
|
||||
// Both should match freely across all AMS units
|
||||
expect(result).toEqual([5, 7]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,7 +159,9 @@ export function FilamentMapping({
|
||||
<option value="" className="bg-bambu-dark text-bambu-gray">
|
||||
-- Select slot --
|
||||
</option>
|
||||
{loadedFilaments.map((f) => (
|
||||
{loadedFilaments
|
||||
.filter((f) => item.nozzle_id == null || f.extruderId === item.nozzle_id)
|
||||
.map((f) => (
|
||||
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
|
||||
{f.label}: {f.type} ({f.colorName})
|
||||
</option>
|
||||
|
||||
@@ -177,7 +177,7 @@ export function ColorSection({
|
||||
key={`${color.hex}-${color.name}`}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
|
||||
isSelected(color.hex)
|
||||
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
@@ -185,7 +185,7 @@ export function ColorSection({
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
>
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
|
||||
{color.name}
|
||||
</span>
|
||||
</button>
|
||||
@@ -220,7 +220,7 @@ export function ColorSection({
|
||||
key={color.hex}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
|
||||
isSelected(color.hex)
|
||||
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
@@ -228,7 +228,7 @@ export function ColorSection({
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
>
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
|
||||
{color.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
|
||||
label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
|
||||
globalTrayId: trayId,
|
||||
trayInfoIdx: extTray.tray_info_idx || '',
|
||||
extruderId: hasDualNozzle ? (trayId - 254) : undefined,
|
||||
extruderId: hasDualNozzle ? (255 - trayId) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -100,12 +100,11 @@ export function computeAmsMapping(
|
||||
// Get available trays (not already used)
|
||||
let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
|
||||
|
||||
// Nozzle-aware filtering: restrict to trays on the correct nozzle
|
||||
// Nozzle-aware filtering: restrict to trays on the correct nozzle.
|
||||
// This is a hard filter — cross-nozzle assignment causes print failures
|
||||
// ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
|
||||
if (req.nozzle_id != null) {
|
||||
const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
|
||||
if (nozzleFiltered.length > 0) {
|
||||
available = nozzleFiltered;
|
||||
}
|
||||
available = available.filter((f) => f.extruderId === req.nozzle_id);
|
||||
}
|
||||
|
||||
let idxMatch: LoadedFilament | undefined;
|
||||
@@ -336,12 +335,10 @@ export function useFilamentMapping(
|
||||
// Get available trays (not already used)
|
||||
let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
|
||||
|
||||
// Nozzle-aware filtering: restrict to trays on the correct nozzle
|
||||
// Nozzle-aware filtering: restrict to trays on the correct nozzle.
|
||||
// This is a hard filter — cross-nozzle assignment causes print failures.
|
||||
if (req.nozzle_id != null) {
|
||||
const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
|
||||
if (nozzleFiltered.length > 0) {
|
||||
available = nozzleFiltered;
|
||||
}
|
||||
available = available.filter((f) => f.extruderId === req.nozzle_id);
|
||||
}
|
||||
|
||||
let idxMatch: LoadedFilament | undefined;
|
||||
|
||||
@@ -64,6 +64,7 @@ import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { ChamberLight } from '../components/icons/ChamberLight';
|
||||
import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
|
||||
import { getGlobalTrayId } from '../utils/amsHelpers';
|
||||
|
||||
// Complete Bambu Lab filament color mapping by tray_id_name
|
||||
// Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
|
||||
@@ -2977,8 +2978,7 @@ function PrinterCard({
|
||||
const hasFillLevel = tray?.tray_type && tray.remain >= 0;
|
||||
const isEmpty = !tray?.tray_type;
|
||||
// Check if this is the currently loaded tray
|
||||
// Global tray ID = ams.id * 4 + tray.id
|
||||
const globalTrayId = ams.id * 4 + (tray?.id ?? 0);
|
||||
const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
|
||||
const isActive = effectiveTrayNow === globalTrayId;
|
||||
// Get cloud preset info if available
|
||||
const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
|
||||
|
||||
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-D8lA4NE5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DF7TfzH1.css">
|
||||
<script type="module" crossorigin src="/assets/index-xpjLLAhl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-tulFiIvt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user