| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779 |
- """
- Tests for the BambuMQTTClient service.
- These tests focus on timelapse tracking during prints.
- """
- import pytest
- class TestTimelapseTracking:
- """Tests for timelapse state tracking during prints."""
- @pytest.fixture
- def mqtt_client(self):
- """Create a BambuMQTTClient instance for testing."""
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- client = BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST123",
- access_code="12345678",
- )
- return client
- def test_timelapse_flag_initializes_to_false(self, mqtt_client):
- """Verify _timelapse_during_print starts as False."""
- assert mqtt_client._timelapse_during_print is False
- def test_timelapse_flag_set_when_timelapse_active_during_running(self, mqtt_client):
- """Verify timelapse flag is set when timelapse is active while printing."""
- # Simulate print running
- mqtt_client._was_running = True
- mqtt_client.state.timelapse = False
- # Simulate xcam data showing timelapse is enabled
- xcam_data = {"timelapse": "enable"}
- mqtt_client._parse_xcam_data(xcam_data)
- assert mqtt_client.state.timelapse is True
- assert mqtt_client._timelapse_during_print is True
- def test_timelapse_flag_not_set_when_not_running(self, mqtt_client):
- """Verify timelapse flag is NOT set when printer not running."""
- # Printer is idle (not running)
- mqtt_client._was_running = False
- mqtt_client.state.timelapse = False
- # Timelapse is enabled but we're not printing
- xcam_data = {"timelapse": "enable"}
- mqtt_client._parse_xcam_data(xcam_data)
- assert mqtt_client.state.timelapse is True
- # Flag should NOT be set since we're not printing
- assert mqtt_client._timelapse_during_print is False
- def test_timelapse_flag_persists_after_timelapse_stops(self, mqtt_client):
- """Verify timelapse flag stays True even after recording stops."""
- # Simulate print running with timelapse
- mqtt_client._was_running = True
- # Enable timelapse during print
- xcam_data = {"timelapse": "enable"}
- mqtt_client._parse_xcam_data(xcam_data)
- assert mqtt_client._timelapse_during_print is True
- # Disable timelapse (recording stops at end of print)
- xcam_data = {"timelapse": "disable"}
- mqtt_client._parse_xcam_data(xcam_data)
- # Flag should still be True (persists until reset)
- assert mqtt_client.state.timelapse is False
- assert mqtt_client._timelapse_during_print is True
- def test_timelapse_flag_from_print_data(self, mqtt_client):
- """Verify timelapse flag is set from print data (not just xcam)."""
- # Simulate print running
- mqtt_client._was_running = True
- mqtt_client.state.timelapse = False
- mqtt_client._timelapse_during_print = False
- # Manually test the timelapse parsing logic from _parse_print_data
- # This tests the "timelapse" field in the main print data
- data = {"timelapse": True}
- mqtt_client.state.timelapse = data["timelapse"] is True
- if mqtt_client.state.timelapse and mqtt_client._was_running:
- mqtt_client._timelapse_during_print = True
- assert mqtt_client._timelapse_during_print is True
- class TestPrintCompletionWithTimelapse:
- """Tests for print completion including timelapse flag."""
- @pytest.fixture
- def mqtt_client(self):
- """Create a BambuMQTTClient instance for testing."""
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- client = BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST123",
- access_code="12345678",
- )
- return client
- def test_print_complete_includes_timelapse_flag(self, mqtt_client):
- """Verify print complete callback includes timelapse_was_active."""
- # Set up completion callback
- callback_data = {}
- def on_complete(data):
- callback_data.update(data)
- mqtt_client.on_print_complete = on_complete
- # Simulate a print that had timelapse active
- mqtt_client._was_running = True
- mqtt_client._completion_triggered = False
- mqtt_client._timelapse_during_print = True
- mqtt_client._previous_gcode_state = "RUNNING"
- mqtt_client._previous_gcode_file = "test.gcode"
- mqtt_client.state.subtask_name = "Test Print"
- # Simulate print finish
- mqtt_client.state.state = "FINISH"
- # Manually trigger the completion logic (simplified)
- # In real code this happens in _parse_print_data
- should_trigger = (
- mqtt_client.state.state in ("FINISH", "FAILED")
- and not mqtt_client._completion_triggered
- and mqtt_client.on_print_complete
- and mqtt_client._previous_gcode_state == "RUNNING"
- )
- if should_trigger:
- status = "completed" if mqtt_client.state.state == "FINISH" else "failed"
- timelapse_was_active = mqtt_client._timelapse_during_print
- mqtt_client._completion_triggered = True
- mqtt_client._was_running = False
- mqtt_client._timelapse_during_print = False
- mqtt_client.on_print_complete(
- {
- "status": status,
- "filename": mqtt_client._previous_gcode_file,
- "subtask_name": mqtt_client.state.subtask_name,
- "timelapse_was_active": timelapse_was_active,
- }
- )
- assert "timelapse_was_active" in callback_data
- assert callback_data["timelapse_was_active"] is True
- def test_print_complete_timelapse_flag_false_when_no_timelapse(self, mqtt_client):
- """Verify timelapse_was_active is False when no timelapse during print."""
- callback_data = {}
- def on_complete(data):
- callback_data.update(data)
- mqtt_client.on_print_complete = on_complete
- # Print without timelapse
- mqtt_client._was_running = True
- mqtt_client._completion_triggered = False
- mqtt_client._timelapse_during_print = False # No timelapse
- mqtt_client._previous_gcode_state = "RUNNING"
- mqtt_client._previous_gcode_file = "test.gcode"
- mqtt_client.state.subtask_name = "Test Print"
- mqtt_client.state.state = "FINISH"
- # Trigger completion
- timelapse_was_active = mqtt_client._timelapse_during_print
- mqtt_client.on_print_complete(
- {
- "status": "completed",
- "filename": mqtt_client._previous_gcode_file,
- "subtask_name": mqtt_client.state.subtask_name,
- "timelapse_was_active": timelapse_was_active,
- }
- )
- assert callback_data["timelapse_was_active"] is False
- def test_timelapse_flag_reset_after_completion(self, mqtt_client):
- """Verify _timelapse_during_print is reset after print completion."""
- mqtt_client._timelapse_during_print = True
- mqtt_client._was_running = True
- mqtt_client._completion_triggered = False
- # Simulate completion reset
- mqtt_client._completion_triggered = True
- mqtt_client._was_running = False
- mqtt_client._timelapse_during_print = False
- assert mqtt_client._timelapse_during_print is False
- class TestRealisticMessageFlow:
- """Tests that simulate realistic MQTT message sequences.
- These tests process messages through _process_message to test the full flow,
- including the order of xcam parsing vs state detection.
- """
- @pytest.fixture
- def mqtt_client(self):
- """Create a BambuMQTTClient instance for testing."""
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- client = BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST123",
- access_code="12345678",
- )
- return client
- def test_timelapse_detected_at_print_start_in_same_message(self, mqtt_client):
- """Test that timelapse is detected when xcam and state come in same message.
- This is the critical race condition test - xcam data is parsed BEFORE
- state detection, so the timelapse flag must be set AFTER _was_running is True.
- """
- # Callbacks to track events
- start_callback_data = {}
- def on_start(data):
- start_callback_data.update(data)
- mqtt_client.on_print_start = on_start
- # Initial state - idle
- mqtt_client._was_running = False
- mqtt_client._timelapse_during_print = False
- mqtt_client._previous_gcode_state = None
- # Simulate first message when print starts - contains both xcam and gcode_state
- # This is the realistic scenario from the printer
- # NOTE: Real MQTT messages wrap print data inside a "print" key
- payload = {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test_print.gcode",
- "subtask_name": "Test_Print",
- "xcam": {
- "timelapse": "enable", # Timelapse is enabled in this print
- "printing_monitor": True,
- },
- "mc_percent": 0,
- "mc_remaining_time": 3600,
- }
- }
- # Process the message (this is what happens in real MQTT flow)
- mqtt_client._process_message(payload)
- # Verify timelapse was detected even though xcam is parsed before state
- assert mqtt_client._was_running is True, "_was_running should be True after RUNNING state"
- assert mqtt_client.state.timelapse is True, "state.timelapse should be True"
- assert mqtt_client._timelapse_during_print is True, (
- "timelapse_during_print should be True when timelapse is in the same message as RUNNING state"
- )
- def test_timelapse_not_detected_when_disabled(self, mqtt_client):
- """Test that timelapse is NOT detected when disabled in xcam data."""
- mqtt_client.on_print_start = lambda data: None
- # Initial state - idle
- mqtt_client._was_running = False
- mqtt_client._timelapse_during_print = False
- mqtt_client._previous_gcode_state = None
- # Print starts without timelapse
- payload = {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test_print.gcode",
- "subtask_name": "Test_Print",
- "xcam": {
- "timelapse": "disable", # Timelapse is disabled
- "printing_monitor": True,
- },
- }
- }
- mqtt_client._process_message(payload)
- assert mqtt_client._was_running is True
- assert mqtt_client.state.timelapse is False
- assert mqtt_client._timelapse_during_print is False
- def test_timelapse_detected_when_enabled_after_print_start(self, mqtt_client):
- """Test timelapse detected when enabled in a message after print starts."""
- mqtt_client.on_print_start = lambda data: None
- # First message - print starts without timelapse info
- payload_start = {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test_print.gcode",
- "subtask_name": "Test_Print",
- }
- }
- mqtt_client._process_message(payload_start)
- assert mqtt_client._was_running is True
- assert mqtt_client._timelapse_during_print is False # Not detected yet
- # Second message - xcam data arrives with timelapse enabled
- payload_xcam = {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test_print.gcode",
- "subtask_name": "Test_Print",
- "xcam": {
- "timelapse": "enable",
- },
- }
- }
- mqtt_client._process_message(payload_xcam)
- # Now timelapse should be detected because _was_running is already True
- assert mqtt_client._timelapse_during_print is True
- def test_print_complete_includes_timelapse_flag_full_flow(self, mqtt_client):
- """Test full print lifecycle with timelapse - from start to completion."""
- start_data = {}
- complete_data = {}
- def on_start(data):
- start_data.update(data)
- def on_complete(data):
- complete_data.update(data)
- mqtt_client.on_print_start = on_start
- mqtt_client.on_print_complete = on_complete
- # 1. Print starts with timelapse
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- "xcam": {"timelapse": "enable"},
- }
- }
- )
- assert mqtt_client._timelapse_during_print is True
- assert "subtask_name" in start_data
- # 2. Print continues (multiple messages)
- for _ in range(3):
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- "mc_percent": 50,
- }
- }
- )
- # Timelapse flag should still be True
- assert mqtt_client._timelapse_during_print is True
- # 3. Print completes
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "FINISH",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- }
- }
- )
- # Verify completion callback received timelapse flag
- assert "timelapse_was_active" in complete_data
- assert complete_data["timelapse_was_active"] is True
- assert complete_data["status"] == "completed"
- # Flags should be reset after completion
- assert mqtt_client._timelapse_during_print is False
- assert mqtt_client._was_running is False
- def test_print_failed_includes_timelapse_flag(self, mqtt_client):
- """Test that failed print also includes timelapse flag."""
- complete_data = {}
- def on_complete(data):
- complete_data.update(data)
- mqtt_client.on_print_start = lambda data: None
- mqtt_client.on_print_complete = on_complete
- # Start with timelapse
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- "xcam": {"timelapse": "enable"},
- }
- }
- )
- # Print fails
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "FAILED",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- }
- }
- )
- assert complete_data["timelapse_was_active"] is True
- assert complete_data["status"] == "failed"
- class TestAMSDataMerging:
- """Tests for AMS data merging, particularly handling empty slots."""
- @pytest.fixture
- def mqtt_client(self):
- """Create a BambuMQTTClient instance for testing."""
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- client = BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST123",
- access_code="12345678",
- )
- return client
- def test_empty_slot_clears_tray_type(self, mqtt_client):
- """Test that empty slot update clears tray_type (Issue #147).
- When a spool is removed from an old AMS, the printer sends empty values.
- These must overwrite the previous values to show the slot as empty.
- """
- # Initial state: AMS unit with a loaded spool
- initial_ams = {
- "ams": [
- {
- "id": 0,
- "tray": [
- {
- "id": 0,
- "tray_type": "PLA",
- "tray_sub_brands": "Bambu PLA Basic",
- "tray_color": "FF0000",
- "tag_uid": "1234567890ABCDEF",
- "remain": 80,
- }
- ],
- }
- ]
- }
- mqtt_client._handle_ams_data(initial_ams)
- # Verify initial state
- ams_data = mqtt_client.state.raw_data.get("ams", [])
- assert len(ams_data) == 1
- tray = ams_data[0]["tray"][0]
- assert tray["tray_type"] == "PLA"
- assert tray["tray_color"] == "FF0000"
- # Now simulate spool removal - printer sends empty values
- empty_update = {
- "ams": [
- {
- "id": 0,
- "tray": [
- {
- "id": 0,
- "tray_type": "", # Empty = slot is empty
- "tray_sub_brands": "",
- "tray_color": "",
- "tag_uid": "0000000000000000", # Zero UID
- "remain": 0,
- }
- ],
- }
- ]
- }
- mqtt_client._handle_ams_data(empty_update)
- # Verify empty values were applied (not ignored by merge logic)
- ams_data = mqtt_client.state.raw_data.get("ams", [])
- tray = ams_data[0]["tray"][0]
- assert tray["tray_type"] == "", "tray_type should be cleared when slot is empty"
- assert tray["tray_color"] == "", "tray_color should be cleared when slot is empty"
- assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
- assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
- def test_partial_update_preserves_other_fields(self, mqtt_client):
- """Test that partial updates still preserve non-slot-status fields."""
- # Initial state with full data
- initial_ams = {
- "ams": [
- {
- "id": 0,
- "humidity": "3",
- "temp": "25.5",
- "tray": [
- {
- "id": 0,
- "tray_type": "PLA",
- "tray_color": "00FF00",
- "remain": 90,
- "k": 0.02,
- }
- ],
- }
- ]
- }
- mqtt_client._handle_ams_data(initial_ams)
- # Partial update - only remain changes
- partial_update = {
- "ams": [
- {
- "id": 0,
- "tray": [
- {
- "id": 0,
- "remain": 85, # Only this changed
- }
- ],
- }
- ]
- }
- mqtt_client._handle_ams_data(partial_update)
- # Verify remain was updated but other fields preserved
- ams_data = mqtt_client.state.raw_data.get("ams", [])
- tray = ams_data[0]["tray"][0]
- assert tray["remain"] == 85, "remain should be updated"
- assert tray["tray_type"] == "PLA", "tray_type should be preserved"
- assert tray["tray_color"] == "00FF00", "tray_color should be preserved"
- assert tray["k"] == 0.02, "k should be preserved"
- def test_tray_exist_bits_clears_empty_slots(self, mqtt_client):
- """Test that tray_exist_bits clears slots marked as empty (Issue #147).
- New AMS models (AMS 2 Pro) don't send empty tray data when a spool is removed.
- Instead, they update tray_exist_bits to indicate which slots have spools.
- """
- # Initial state: AMS 0 and AMS 1 with loaded spools
- initial_ams = {
- "ams": [
- {
- "id": 0,
- "tray": [
- {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
- {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "remain": 60},
- {"id": 2, "tray_type": "ABS", "tray_color": "0000FF", "remain": 40},
- {"id": 3, "tray_type": "TPU", "tray_color": "FFFF00", "remain": 20},
- ],
- },
- {
- "id": 1,
- "tray": [
- {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFF", "remain": 90},
- {"id": 1, "tray_type": "PLA", "tray_color": "000000", "remain": 70},
- {"id": 2, "tray_type": "PLA", "tray_color": "FF00FF", "remain": 50},
- {"id": 3, "tray_type": "PLA", "tray_color": "00FFFF", "remain": 30},
- ],
- },
- ],
- "tray_exist_bits": "ff", # All 8 slots have spools (0xFF = 11111111)
- }
- mqtt_client._handle_ams_data(initial_ams)
- # Verify initial state
- ams_data = mqtt_client.state.raw_data.get("ams", [])
- assert ams_data[1]["tray"][3]["tray_type"] == "PLA" # AMS 1 slot 3 (B4) has spool
- # Now simulate spool removal from AMS 1 slot 3 (B4)
- # tray_exist_bits: 0x7f = 01111111 (bit 7 = 0 means AMS 1 slot 3 is empty)
- update_ams = {
- "ams": [
- {"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
- {"id": 1, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
- ],
- "tray_exist_bits": "7f", # Bit 7 = 0 -> AMS 1 slot 3 is empty
- }
- mqtt_client._handle_ams_data(update_ams)
- # Verify AMS 1 slot 3 was cleared
- ams_data = mqtt_client.state.raw_data.get("ams", [])
- b4_tray = ams_data[1]["tray"][3]
- assert b4_tray["tray_type"] == "", "tray_type should be cleared for empty slot"
- assert b4_tray["remain"] == 0, "remain should be 0 for empty slot"
- # Verify other slots are preserved
- assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
- assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
- class TestNozzleRackData:
- """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
- @pytest.fixture
- def mqtt_client(self):
- """Create a BambuMQTTClient instance for testing."""
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- client = BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST123",
- access_code="12345678",
- )
- return client
- def test_h2c_nozzle_rack_populated_with_8_entries(self, mqtt_client):
- """H2C provides 8 nozzle entries: IDs 0,1 (L/R hotend) + 16-21 (rack)."""
- payload = {
- "print": {
- "device": {
- "nozzle": {
- "info": [
- {
- "id": 0,
- "type": "HS",
- "diameter": "0.4",
- "wear": 5,
- "stat": 1,
- "max_temp": 300,
- "serial_number": "SN-L",
- },
- {
- "id": 1,
- "type": "HS",
- "diameter": "0.4",
- "wear": 3,
- "stat": 0,
- "max_temp": 300,
- "serial_number": "SN-R",
- },
- {
- "id": 16,
- "type": "HS",
- "diameter": "0.4",
- "wear": 10,
- "stat": 0,
- "max_temp": 300,
- "serial_number": "SN-16",
- },
- {
- "id": 17,
- "type": "HH01",
- "diameter": "0.6",
- "wear": 0,
- "stat": 0,
- "max_temp": 300,
- "serial_number": "SN-17",
- },
- {
- "id": 18,
- "type": "HS",
- "diameter": "0.4",
- "wear": 2,
- "stat": 0,
- "max_temp": 300,
- "serial_number": "SN-18",
- },
- {
- "id": 19,
- "type": "",
- "diameter": "",
- "wear": None,
- "stat": None,
- "max_temp": 0,
- "serial_number": "",
- },
- {
- "id": 20,
- "type": "",
- "diameter": "",
- "wear": None,
- "stat": None,
- "max_temp": 0,
- "serial_number": "",
- },
- {
- "id": 21,
- "type": "",
- "diameter": "",
- "wear": None,
- "stat": None,
- "max_temp": 0,
- "serial_number": "",
- },
- ]
- }
- }
- }
- }
- mqtt_client._process_message(payload)
- assert len(mqtt_client.state.nozzle_rack) == 8
- ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
- assert ids == [0, 1, 16, 17, 18, 19, 20, 21]
- def test_h2d_nozzle_rack_populated_with_2_entries(self, mqtt_client):
- """H2D provides 2 nozzle entries: IDs 0,1 (L/R hotend) — no rack slots."""
- payload = {
- "print": {
- "device": {
- "nozzle": {
- "info": [
- {
- "id": 0,
- "type": "HS",
- "diameter": "0.4",
- "wear": 5,
- "stat": 1,
- "max_temp": 300,
- "serial_number": "SN-L",
- },
- {
- "id": 1,
- "type": "HS",
- "diameter": "0.4",
- "wear": 3,
- "stat": 1,
- "max_temp": 300,
- "serial_number": "SN-R",
- },
- ]
- }
- }
- }
- }
- mqtt_client._process_message(payload)
- assert len(mqtt_client.state.nozzle_rack) == 2
- ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
- assert ids == [0, 1]
- def test_single_nozzle_h2s_populated(self, mqtt_client):
- """H2S provides 1 nozzle entry: ID 0 only — single nozzle printer."""
- payload = {
- "print": {
- "device": {
- "nozzle": {
- "info": [
- {
- "id": 0,
- "type": "HS",
- "diameter": "0.4",
- "wear": 2,
- "stat": 1,
- "max_temp": 300,
- "serial_number": "SN-0",
- },
- ]
- }
- }
- }
- }
- mqtt_client._process_message(payload)
- assert len(mqtt_client.state.nozzle_rack) == 1
- assert mqtt_client.state.nozzle_rack[0]["id"] == 0
- def test_empty_nozzle_info_does_not_populate_rack(self, mqtt_client):
- """Empty nozzle info list should not populate nozzle_rack."""
- payload = {"print": {"device": {"nozzle": {"info": []}}}}
- mqtt_client._process_message(payload)
- assert mqtt_client.state.nozzle_rack == []
- def test_nozzle_rack_sorted_by_id(self, mqtt_client):
- """Nozzle rack entries should be sorted by ID regardless of input order."""
- payload = {
- "print": {
- "device": {
- "nozzle": {
- "info": [
- {"id": 17, "type": "HS", "diameter": "0.6"},
- {"id": 0, "type": "HS", "diameter": "0.4"},
- {"id": 16, "type": "HS", "diameter": "0.4"},
- {"id": 1, "type": "HS", "diameter": "0.4"},
- ]
- }
- }
- }
- }
- mqtt_client._process_message(payload)
- ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
- assert ids == [0, 1, 16, 17]
- def test_nozzle_rack_field_mapping(self, mqtt_client):
- """Verify field mapping from MQTT nozzle_info to nozzle_rack dict keys."""
- payload = {
- "print": {
- "device": {
- "nozzle": {
- "info": [
- {
- "id": 16,
- "type": "HH01",
- "diameter": "0.6",
- "wear": 15,
- "stat": 0,
- "max_temp": 320,
- "serial_number": "SN-ABC123",
- "filament_colour": "FF8800",
- "filament_id": "F42",
- "tray_type": "ABS",
- }
- ]
- }
- }
- }
- }
- mqtt_client._process_message(payload)
- slot = mqtt_client.state.nozzle_rack[0]
- assert slot["id"] == 16
- assert slot["type"] == "HH01"
- assert slot["diameter"] == "0.6"
- assert slot["wear"] == 15
- assert slot["stat"] == 0
- assert slot["max_temp"] == 320
- assert slot["serial_number"] == "SN-ABC123"
- assert slot["filament_color"] == "FF8800"
- assert slot["filament_id"] == "F42"
- assert slot["filament_type"] == "ABS"
- def test_nozzle_info_updates_nozzle_state(self, mqtt_client):
- """Nozzle info for IDs 0,1 should also update nozzle state (type/diameter)."""
- payload = {
- "print": {
- "device": {
- "nozzle": {
- "info": [
- {"id": 0, "type": "HS", "diameter": "0.4"},
- {"id": 1, "type": "HH01", "diameter": "0.6"},
- ]
- }
- }
- }
- }
- mqtt_client._process_message(payload)
- assert mqtt_client.state.nozzles[0].nozzle_type == "HS"
- assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
- assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
- assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"
- class TestRequestTopicFailSafe:
- """Tests for graceful degradation when broker rejects request topic subscription."""
- @pytest.fixture
- def mqtt_client(self):
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- client = BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST123",
- access_code="12345678",
- )
- return client
- def test_request_topic_supported_by_default(self, mqtt_client):
- """Request topic subscription is attempted by default."""
- assert mqtt_client._request_topic_supported is True
- assert mqtt_client._request_topic_confirmed is False
- def test_on_subscribe_confirms_success(self, mqtt_client):
- """Successful SUBACK marks request topic as confirmed."""
- from paho.mqtt.reasoncodes import ReasonCode
- mqtt_client._request_topic_sub_mid = 42
- rc = ReasonCode(9, identifier=0) # SUBACK packetType=9, QoS 0 = success
- mqtt_client._on_subscribe(None, None, 42, [rc], None)
- assert mqtt_client._request_topic_confirmed is True
- assert mqtt_client._request_topic_supported is True
- assert mqtt_client._request_topic_sub_mid is None
- assert mqtt_client._request_topic_sub_time == 0.0
- def test_on_subscribe_detects_rejection(self, mqtt_client):
- """SUBACK with failure code disables request topic."""
- from paho.mqtt.reasoncodes import ReasonCode
- mqtt_client._request_topic_sub_mid = 42
- rc = ReasonCode(9, identifier=0x80) # SUBACK packetType=9, 0x80 = failure
- mqtt_client._on_subscribe(None, None, 42, [rc], None)
- assert mqtt_client._request_topic_supported is False
- assert mqtt_client._request_topic_confirmed is False
- def test_on_subscribe_ignores_other_mids(self, mqtt_client):
- """SUBACK for other subscriptions (e.g. report topic) is ignored."""
- from paho.mqtt.reasoncodes import ReasonCode
- mqtt_client._request_topic_sub_mid = 42
- rc = ReasonCode(9, identifier=0x80)
- mqtt_client._on_subscribe(None, None, 99, [rc], None)
- # Not affected — mid doesn't match
- assert mqtt_client._request_topic_supported is True
- def test_disconnect_after_subscription_disables_topic(self, mqtt_client):
- """Disconnect within 10s of subscription attempt disables request topic."""
- import time
- mqtt_client._request_topic_sub_time = time.time()
- mqtt_client._request_topic_confirmed = False
- mqtt_client._last_message_time = 0.0
- mqtt_client._on_disconnect(None, None)
- assert mqtt_client._request_topic_supported is False
- assert mqtt_client._request_topic_sub_time == 0.0
- def test_disconnect_after_confirmation_does_not_disable(self, mqtt_client):
- """Disconnect after SUBACK confirmation keeps request topic enabled."""
- import time
- mqtt_client._request_topic_sub_time = time.time()
- mqtt_client._request_topic_confirmed = True
- mqtt_client._last_message_time = 0.0
- mqtt_client._on_disconnect(None, None)
- assert mqtt_client._request_topic_supported is True
- def test_late_disconnect_does_not_disable(self, mqtt_client):
- """Disconnect long after subscription (>10s) doesn't blame request topic."""
- import time
- mqtt_client._request_topic_sub_time = time.time() - 30.0
- mqtt_client._request_topic_confirmed = False
- mqtt_client._last_message_time = 0.0
- mqtt_client._on_disconnect(None, None)
- assert mqtt_client._request_topic_supported is True
- def test_on_connect_skips_request_topic_when_unsupported(self, mqtt_client):
- """After marking unsupported, reconnect skips request topic subscription."""
- mqtt_client._request_topic_supported = False
- subscribe_calls = []
- mock_client = type(
- "MockClient",
- (),
- {
- "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
- },
- )()
- mqtt_client._on_connect(mock_client, None, None, 0)
- # Only report topic subscribed, not request topic
- assert len(subscribe_calls) == 1
- assert subscribe_calls[0] == mqtt_client.topic_subscribe
- class TestRequestTopicAmsMapping:
- """Tests for capturing ams_mapping from the MQTT request topic."""
- @pytest.fixture
- def mqtt_client(self):
- """Create a BambuMQTTClient instance for testing."""
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- client = BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST123",
- access_code="12345678",
- )
- return client
- def test_captured_ams_mapping_initializes_to_none(self, mqtt_client):
- """Verify _captured_ams_mapping starts as None."""
- assert mqtt_client._captured_ams_mapping is None
- def test_handle_request_message_captures_ams_mapping(self, mqtt_client):
- """project_file command with ams_mapping stores the mapping."""
- data = {
- "print": {
- "command": "project_file",
- "ams_mapping": [0, 4, -1, -1],
- "url": "ftp://192.168.1.100/test.3mf",
- }
- }
- mqtt_client._handle_request_message(data)
- assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
- def test_handle_request_message_ignores_non_print_commands(self, mqtt_client):
- """Non-project_file commands don't store ams_mapping."""
- data = {
- "print": {
- "command": "pause",
- }
- }
- mqtt_client._handle_request_message(data)
- assert mqtt_client._captured_ams_mapping is None
- def test_handle_request_message_ignores_missing_ams_mapping(self, mqtt_client):
- """project_file command without ams_mapping doesn't store anything."""
- data = {
- "print": {
- "command": "project_file",
- "url": "ftp://192.168.1.100/test.3mf",
- }
- }
- mqtt_client._handle_request_message(data)
- assert mqtt_client._captured_ams_mapping is None
- def test_handle_request_message_ignores_non_dict_print(self, mqtt_client):
- """Non-dict print value is safely ignored."""
- data = {"print": "not_a_dict"}
- mqtt_client._handle_request_message(data)
- assert mqtt_client._captured_ams_mapping is None
- def test_handle_request_message_ignores_missing_print(self, mqtt_client):
- """Message without print key is safely ignored."""
- data = {"pushing": {"command": "pushall"}}
- mqtt_client._handle_request_message(data)
- assert mqtt_client._captured_ams_mapping is None
- def test_captured_mapping_overwrites_previous(self, mqtt_client):
- """A new print command overwrites a previously captured mapping."""
- mqtt_client._captured_ams_mapping = [0, -1, -1, -1]
- data = {
- "print": {
- "command": "project_file",
- "ams_mapping": [4, 8, -1, -1],
- }
- }
- mqtt_client._handle_request_message(data)
- assert mqtt_client._captured_ams_mapping == [4, 8, -1, -1]
- def test_print_start_callback_includes_ams_mapping(self, mqtt_client):
- """on_print_start callback data includes captured ams_mapping."""
- start_data = {}
- def on_start(data):
- start_data.update(data)
- mqtt_client.on_print_start = on_start
- mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
- # Trigger print start
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- }
- }
- )
- assert start_data.get("ams_mapping") == [0, 4, -1, -1]
- def test_print_start_callback_ams_mapping_none_when_not_captured(self, mqtt_client):
- """on_print_start callback has ams_mapping=None when no mapping captured."""
- start_data = {}
- def on_start(data):
- start_data.update(data)
- mqtt_client.on_print_start = on_start
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- }
- }
- )
- assert "ams_mapping" in start_data
- assert start_data["ams_mapping"] is None
- def test_print_complete_callback_includes_ams_mapping(self, mqtt_client):
- """on_print_complete callback data includes captured ams_mapping."""
- complete_data = {}
- def on_complete(data):
- complete_data.update(data)
- mqtt_client.on_print_start = lambda d: None
- mqtt_client.on_print_complete = on_complete
- mqtt_client._captured_ams_mapping = [0, 9, -1, -1]
- # Start print
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- }
- }
- )
- # Complete print
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "FINISH",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- }
- }
- )
- assert complete_data.get("ams_mapping") == [0, 9, -1, -1]
- def test_captured_mapping_cleared_after_print_complete(self, mqtt_client):
- """_captured_ams_mapping is reset to None after print completion."""
- mqtt_client.on_print_start = lambda d: None
- mqtt_client.on_print_complete = lambda d: None
- mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
- # Start print
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- }
- }
- )
- # Complete print
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "FINISH",
- "gcode_file": "/data/Metadata/test.gcode",
- "subtask_name": "Test",
- }
- }
- )
- assert mqtt_client._captured_ams_mapping is None
- def test_full_flow_capture_and_deliver(self, mqtt_client):
- """Full flow: slicer sends print command → MQTT captures mapping → completion delivers it."""
- complete_data = {}
- def on_complete(data):
- complete_data.update(data)
- mqtt_client.on_print_start = lambda d: None
- mqtt_client.on_print_complete = on_complete
- # 1. Slicer sends print command (captured from request topic)
- mqtt_client._handle_request_message(
- {
- "print": {
- "command": "project_file",
- "ams_mapping": [4, 9, -1, -1],
- "url": "ftp://192.168.1.100/model.3mf",
- }
- }
- )
- assert mqtt_client._captured_ams_mapping == [4, 9, -1, -1]
- # 2. Printer reports RUNNING
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "RUNNING",
- "gcode_file": "/data/Metadata/model.gcode",
- "subtask_name": "Model",
- }
- }
- )
- # 3. Printer reports FINISH
- mqtt_client._process_message(
- {
- "print": {
- "gcode_state": "FINISH",
- "gcode_file": "/data/Metadata/model.gcode",
- "subtask_name": "Model",
- }
- }
- )
- assert complete_data["ams_mapping"] == [4, 9, -1, -1]
- assert complete_data["status"] == "completed"
- # Mapping cleared after completion
- assert mqtt_client._captured_ams_mapping is None
- # ---------------------------------------------------------------------------
- # tray_now disambiguation helpers
- # ---------------------------------------------------------------------------
- def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None):
- """Build minimal print.ams payload for tray_now disambiguation tests."""
- ams = {"tray_now": str(tray_now)}
- if ams_units is not None:
- ams["ams"] = ams_units
- if tray_exist_bits is not None:
- ams["tray_exist_bits"] = tray_exist_bits
- return {"print": {"ams": ams}}
- def _extruder_info_payload(extruders):
- """Build device.extruder.info payload (dual-nozzle detection + snow).
- Each entry in *extruders* is a dict with at least ``id`` and ``snow``.
- """
- return {
- "print": {
- "device": {
- "extruder": {
- "info": extruders,
- }
- }
- }
- }
- def _extruder_state_payload(state_val):
- """Build device.extruder.state payload (active extruder via bit 8)."""
- return {
- "print": {
- "device": {
- "extruder": {
- "state": state_val,
- }
- }
- }
- }
- # ---------------------------------------------------------------------------
- # 1. Single-nozzle X1E — direct passthrough
- # ---------------------------------------------------------------------------
- class TestTrayNowSingleNozzleX1E:
- """Single-nozzle, 1 AMS — tray_now is a direct passthrough."""
- @pytest.fixture
- def mqtt_client(self):
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- return BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST_X1E",
- access_code="12345678",
- )
- def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):
- """Each tray_now 0-3 maps 1:1 on single-nozzle printers."""
- for slot in range(4):
- mqtt_client._process_message(_ams_payload(slot))
- assert mqtt_client.state.tray_now == slot
- def test_tray_now_255_means_unloaded(self, mqtt_client):
- """tray_now=255 means no filament loaded."""
- mqtt_client._process_message(_ams_payload(255))
- assert mqtt_client.state.tray_now == 255
- def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):
- """device.extruder.info with 1 entry must NOT set _is_dual_nozzle."""
- mqtt_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0xFF00FF}]))
- assert mqtt_client._is_dual_nozzle is False
- def test_last_loaded_tray_survives_unload(self, mqtt_client):
- """Load tray 2, unload → last_loaded_tray stays 2."""
- mqtt_client._process_message(_ams_payload(2))
- assert mqtt_client.state.last_loaded_tray == 2
- mqtt_client._process_message(_ams_payload(255))
- assert mqtt_client.state.tray_now == 255
- assert mqtt_client.state.last_loaded_tray == 2
- # ---------------------------------------------------------------------------
- # 2. Single-nozzle P2S — multiple AMS, global IDs pass through
- # ---------------------------------------------------------------------------
- class TestTrayNowSingleNozzleP2S:
- """Single-nozzle, 2 AMS — global IDs 4-7 for AMS 1 pass through directly."""
- @pytest.fixture
- def mqtt_client(self):
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- return BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST_P2S",
- access_code="12345678",
- )
- def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):
- """tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers."""
- for global_id in range(4, 8):
- mqtt_client._process_message(_ams_payload(global_id))
- assert mqtt_client.state.tray_now == global_id
- def test_tray_change_across_ams_units(self, mqtt_client):
- """Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6)."""
- mqtt_client._process_message(_ams_payload(1))
- assert mqtt_client.state.tray_now == 1
- mqtt_client._process_message(_ams_payload(6))
- assert mqtt_client.state.tray_now == 6
- # ---------------------------------------------------------------------------
- # 3. H2D Pro — initial state detection
- # ---------------------------------------------------------------------------
- class TestTrayNowDualNozzleH2DSetup:
- """H2D Pro initial state detection."""
- @pytest.fixture
- def mqtt_client(self):
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- return BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST_H2D",
- access_code="12345678",
- )
- def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):
- """2 entries in device.extruder.info → _is_dual_nozzle=True."""
- mqtt_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0xFF00FF},
- {"id": 1, "snow": 0xFF00FF},
- ]
- )
- )
- assert mqtt_client._is_dual_nozzle is True
- def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
- """AMS 0 info=2003 → right (ext 0), AMS 128 info=2104 → left (ext 1)."""
- ams_units = [
- {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
- {"id": 128, "info": 2104, "tray": [{"id": 0}]},
- ]
- payload = {
- "print": {
- "ams": {
- "ams": ams_units,
- "tray_now": "255",
- "tray_exist_bits": "1000f",
- },
- }
- }
- mqtt_client._process_message(payload)
- # info=2003: bit8 = (2003>>8)&1 = 7&1 = 1 → extruder = 1-1 = 0 (right)
- # info=2104: bit8 = (2104>>8)&1 = 8&1 = 0 → extruder = 1-0 = 1 (left)
- assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
- def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
- """Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.
- If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.
- """
- payload = {
- "print": {
- "device": {
- "extruder": {
- "info": [
- {"id": 0, "snow": 0xFF00FF},
- {"id": 1, "snow": 0xFF00FF},
- ],
- "state": 0x0001,
- }
- },
- "ams": {
- "ams": [
- {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
- ],
- "tray_now": "2",
- "tray_exist_bits": "f",
- },
- }
- }
- mqtt_client._process_message(payload)
- # Dual-nozzle was detected; AMS 0 on right extruder (active by default);
- # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.
- # Single AMS on extruder 0 → global_id = 0*4+2 = 2
- assert mqtt_client._is_dual_nozzle is True
- assert mqtt_client.state.tray_now == 2
- # ---------------------------------------------------------------------------
- # Shared H2D fixture for classes 4-8
- # ---------------------------------------------------------------------------
- class _H2DFixtureMixin:
- """Mixin providing a pre-configured H2D Pro client."""
- @pytest.fixture
- def mqtt_client(self):
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- return BambuMQTTClient(
- ip_address="192.168.1.100",
- serial_number="TEST_H2D",
- access_code="12345678",
- )
- @pytest.fixture
- def h2d_client(self, mqtt_client):
- """Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map."""
- mqtt_client._process_message(
- {
- "print": {
- "device": {
- "extruder": {
- "info": [
- {"id": 0, "snow": 0xFF00FF},
- {"id": 1, "snow": 0xFF00FF},
- ],
- "state": 0x0001, # right extruder active
- }
- },
- "ams": {
- "ams": [
- {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
- {"id": 128, "info": 2104, "tray": [{"id": 0}]},
- ],
- "tray_now": "255",
- "tray_exist_bits": "1000f",
- },
- }
- }
- )
- assert mqtt_client._is_dual_nozzle is True
- assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
- return mqtt_client
- # ---------------------------------------------------------------------------
- # 4. H2D Snow field disambiguation
- # ---------------------------------------------------------------------------
- class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
- """Snow field disambiguation (primary path)."""
- def test_snow_disambiguates_ams0_slot(self, h2d_client):
- """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
- # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
- # so we need it in a prior message).
- snow_val = 0 << 8 | 2 # AMS 0 slot 2 = raw 2
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": snow_val},
- {"id": 1, "snow": 0xFF00FF},
- ]
- )
- )
- assert h2d_client.state.h2d_extruder_snow.get(0) == 2
- # Now send tray_now=2
- h2d_client._process_message(_ams_payload(2))
- assert h2d_client.state.tray_now == 2
- def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
- """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
- # Snow: extruder 1 → AMS 128 slot 0
- snow_val = 128 << 8 | 0 # = 32768
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0xFF00FF},
- {"id": 1, "snow": snow_val},
- ]
- )
- )
- assert h2d_client.state.h2d_extruder_snow.get(1) == 128
- # Switch to left extruder
- h2d_client._process_message(_extruder_state_payload(0x0100))
- assert h2d_client.state.active_extruder == 1
- # tray_now="0" with left extruder active, snow says AMS HT (128)
- # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match
- h2d_client._process_message(_ams_payload(0))
- assert h2d_client.state.tray_now == 128
- def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):
- """Verify state.h2d_extruder_snow dict is populated correctly."""
- snow_ext0 = 1 << 8 | 3 # AMS 1 slot 3 → global 7
- snow_ext1 = 0 << 8 | 0 # AMS 0 slot 0 → global 0
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": snow_ext0},
- {"id": 1, "snow": snow_ext1},
- ]
- )
- )
- assert h2d_client.state.h2d_extruder_snow[0] == 7
- assert h2d_client.state.h2d_extruder_snow[1] == 0
- def test_snow_unloaded_value(self, h2d_client):
- """snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded)."""
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0xFFFF},
- {"id": 1, "snow": 0xFFFF},
- ]
- )
- )
- assert h2d_client.state.h2d_extruder_snow[0] == 255
- assert h2d_client.state.h2d_extruder_snow[1] == 255
- def test_snow_initial_sentinel_not_stored(self, h2d_client):
- """snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow."""
- # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0xFF00FF},
- {"id": 1, "snow": 0xFF00FF},
- ]
- )
- )
- # Snow dict should remain empty (no matching branch)
- assert h2d_client.state.h2d_extruder_snow == {}
- # ---------------------------------------------------------------------------
- # 5. H2D Pending target disambiguation
- # ---------------------------------------------------------------------------
- class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
- """Pending target disambiguation (when Bambuddy initiates load)."""
- def test_pending_target_matches_slot(self, h2d_client):
- """pending=5, tray_now='1' (5%4=1 matches) → tray_now=5."""
- h2d_client.state.pending_tray_target = 5
- h2d_client._process_message(_ams_payload(1))
- assert h2d_client.state.tray_now == 5
- assert h2d_client.state.pending_tray_target is None # cleared
- def test_pending_target_slot_mismatch(self, h2d_client):
- """pending=5, tray_now='2' → uses raw slot, clears pending."""
- h2d_client.state.pending_tray_target = 5
- h2d_client._process_message(_ams_payload(2))
- # Slot 2 != 5%4=1 → mismatch, uses raw slot 2
- assert h2d_client.state.tray_now == 2
- assert h2d_client.state.pending_tray_target is None
- def test_pending_target_takes_priority_over_snow(self, h2d_client):
- """When both pending and snow are set, pending wins."""
- # Set up snow for extruder 0 → AMS 0 slot 1 → global 1
- snow_val = 0 << 8 | 1
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": snow_val},
- {"id": 1, "snow": 0xFF00FF},
- ]
- )
- )
- assert h2d_client.state.h2d_extruder_snow.get(0) == 1
- # Set pending target to AMS 1 slot 1 (global 5)
- h2d_client.state.pending_tray_target = 5
- # tray_now="1" — matches pending (5%4=1), pending should win over snow
- h2d_client._process_message(_ams_payload(1))
- assert h2d_client.state.tray_now == 5
- # ---------------------------------------------------------------------------
- # 6. H2D ams_extruder_map fallback
- # ---------------------------------------------------------------------------
- class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
- """ams_extruder_map fallback (no pending, no snow)."""
- def test_single_ams_on_extruder_computes_global_id(self, h2d_client):
- """AMS 0 on right extruder, tray_now='2' → 0*4+2=2."""
- # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips
- h2d_client._process_message(_ams_payload(2))
- # AMS 0 is the only AMS on extruder 0 (right, active by default)
- # Fallback: single AMS → global = 0*4+2 = 2
- assert h2d_client.state.tray_now == 2
- def test_multiple_ams_keeps_current_if_valid(self, h2d_client):
- """Current tray matches slot → keeps it (multi-AMS on same extruder)."""
- # Set up: two AMS units on the same extruder (right, ext 0)
- h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
- # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder
- h2d_client.state.tray_now = 5
- # tray_now="1" → 5%4=1 matches → keep current=5
- h2d_client._process_message(_ams_payload(1))
- assert h2d_client.state.tray_now == 5
- def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):
- """No AMS mapped to the active extruder → raw slot as global ID."""
- # All AMS on left extruder, but right is active
- h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
- h2d_client._process_message(_ams_payload(2))
- assert h2d_client.state.tray_now == 2
- # ---------------------------------------------------------------------------
- # 7. H2D Active extruder switching
- # ---------------------------------------------------------------------------
- class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
- """Active extruder switching via device.extruder.state bit 8."""
- def test_active_extruder_right_by_default(self, h2d_client):
- """Initial state.active_extruder == 0 (right)."""
- assert h2d_client.state.active_extruder == 0
- def test_extruder_state_bit8_switches_to_left(self, h2d_client):
- """state=0x100 → active_extruder=1 (left)."""
- h2d_client._process_message(_extruder_state_payload(0x0100))
- assert h2d_client.state.active_extruder == 1
- def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):
- """Cycle 0 → 1 → 0."""
- h2d_client._process_message(_extruder_state_payload(0x0100))
- assert h2d_client.state.active_extruder == 1
- h2d_client._process_message(_extruder_state_payload(0x0001))
- assert h2d_client.state.active_extruder == 0
- def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):
- """Snow on both extruders; switching active changes which snow is used."""
- # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0 << 8 | 1}, # AMS 0 slot 1 → global 1
- {"id": 1, "snow": 128 << 8 | 0}, # AMS HT → global 128
- ]
- )
- )
- # Right active (default) — tray_now="1" → snow ext[0] says global 1
- h2d_client._process_message(_ams_payload(1))
- assert h2d_client.state.tray_now == 1
- # Switch to left
- h2d_client._process_message(_extruder_state_payload(0x0100))
- # Left active — tray_now="0" → snow ext[1] says AMS HT (128), slot 0 matches
- h2d_client._process_message(_ams_payload(0))
- assert h2d_client.state.tray_now == 128
- # ---------------------------------------------------------------------------
- # 8. H2D Full multi-message sequences
- # ---------------------------------------------------------------------------
- class TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):
- """Multi-message sequences simulating real H2D Pro prints."""
- def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):
- """Setup → load AMS 0 slot 1 → verify tray_now=1."""
- # Snow update: extruder 0 loading AMS 0 slot 1
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0 << 8 | 1},
- {"id": 1, "snow": 0xFF00FF},
- ]
- )
- )
- # Printer reports tray_now="1"
- h2d_client._process_message(_ams_payload(1))
- assert h2d_client.state.tray_now == 1
- assert h2d_client.state.last_loaded_tray == 1
- def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):
- """Setup → switch left → load AMS HT → verify tray_now=128."""
- # Switch to left extruder
- h2d_client._process_message(_extruder_state_payload(0x0100))
- # Snow: ext 1 → AMS HT slot 0
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0xFF00FF},
- {"id": 1, "snow": 128 << 8 | 0},
- ]
- )
- )
- # Printer reports tray_now="0" (AMS HT single slot)
- h2d_client._process_message(_ams_payload(0))
- assert h2d_client.state.tray_now == 128
- assert h2d_client.state.last_loaded_tray == 128
- def test_h2d_multi_color_alternating_nozzles(self, h2d_client):
- """Multi-color print alternating between right and left nozzles.
- Sequence:
- 1. Right loads AMS 0 slot 0 (tray=0)
- 2. Switch left, load AMS HT (tray=128)
- 3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)
- 4. Unload (255)
- """
- # Step 1: Right extruder loads AMS 0 slot 0
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0 << 8 | 0},
- {"id": 1, "snow": 0xFF00FF},
- ]
- )
- )
- h2d_client._process_message(_ams_payload(0))
- assert h2d_client.state.tray_now == 0
- # Step 2: Switch to left, load AMS HT
- h2d_client._process_message(_extruder_state_payload(0x0100))
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0 << 8 | 0},
- {"id": 1, "snow": 128 << 8 | 0},
- ]
- )
- )
- h2d_client._process_message(_ams_payload(0))
- assert h2d_client.state.tray_now == 128
- # Step 3: Switch back to right, load AMS 0 slot 2
- h2d_client._process_message(_extruder_state_payload(0x0001))
- h2d_client._process_message(
- _extruder_info_payload(
- [
- {"id": 0, "snow": 0 << 8 | 2},
- {"id": 1, "snow": 128 << 8 | 0},
- ]
- )
- )
- h2d_client._process_message(_ams_payload(2))
- assert h2d_client.state.tray_now == 2
- # Step 4: Unload
- h2d_client._process_message(_ams_payload(255))
- assert h2d_client.state.tray_now == 255
- assert h2d_client.state.last_loaded_tray == 2
|