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:
maziggy
2026-02-18 09:17:50 +01:00
parent 157dca27b7
commit 29e9593f6c
14 changed files with 1132 additions and 432 deletions
+3 -2
View File
@@ -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
+14
View File
@@ -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
+5 -5
View File
@@ -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:
+12
View File
@@ -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 (
+40 -22
View File
@@ -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
+363 -11
View File
@@ -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>
+8 -11
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-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>