|
@@ -6738,3 +6738,242 @@ class TestConnectRefusalReporting:
|
|
|
|
|
|
|
|
assert "MQTT disconnected" in caplog.text
|
|
assert "MQTT disconnected" in caplog.text
|
|
|
assert "refused" not in caplog.text
|
|
assert "refused" not in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestEndOfPrintProbe:
|
|
|
|
|
+ """Tests for #2547: the end-of-print telemetry probe.
|
|
|
|
|
+
|
|
|
|
|
+ The probe exists to answer a question no existing support bundle can:
|
|
|
|
|
+ what do the stage/action fields do between the last object layer and
|
|
|
|
|
+ gcode_state=FINISH? stg_cur=22 was supposed to mark "toolhead parked,
|
|
|
|
|
+ before filament unload" (#1721) and fires on no model in the field, and
|
|
|
|
|
+ Bambuddy drops every other stage field unread. These tests pin the
|
|
|
|
|
+ window's boundaries and the guarantee that instrumentation stays
|
|
|
|
|
+ instrumentation — it must never raise into the ingest path.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ LOGGER = "backend.app.services.bambu_mqtt"
|
|
|
|
|
+
|
|
|
|
|
+ @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",
|
|
|
|
|
+ )
|
|
|
|
|
+ client._was_running = True
|
|
|
|
|
+ client.state.state = "RUNNING"
|
|
|
|
|
+ client.state.total_layers = 100
|
|
|
|
|
+ client.state.layer_num = 98
|
|
|
|
|
+ client.state.progress = 90.0
|
|
|
|
|
+ client.state.remaining_time = 12
|
|
|
|
|
+ return client
|
|
|
|
|
+
|
|
|
|
|
+ def test_silent_when_debug_logging_is_off(self, mqtt_client, caplog):
|
|
|
|
|
+ """The probe is a debug tool; at INFO it must cost nothing and say
|
|
|
|
|
+ nothing, including for a frame that would otherwise open the window."""
|
|
|
|
|
+ with caplog.at_level(logging.INFO, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE" not in caplog.text
|
|
|
|
|
+ assert mqtt_client._eop_probe_open is False
|
|
|
|
|
+
|
|
|
|
|
+ def test_does_not_open_mid_print(self, mqtt_client, caplog):
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 99, "mc_percent": 91}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE" not in caplog.text
|
|
|
|
|
+ assert mqtt_client._eop_probe_open is False
|
|
|
|
|
+
|
|
|
|
|
+ def test_opens_on_the_last_layer_frame_itself(self, mqtt_client, caplog):
|
|
|
|
|
+ """The frame carrying the signal must be captured, not just the ones
|
|
|
|
|
+ after it — so the probe has to read the raw frame rather than state,
|
|
|
|
|
+ which _update_state only updates further down the same call."""
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE open" in caplog.text
|
|
|
|
|
+ assert "'layer_num': 100" in caplog.text
|
|
|
|
|
+ assert mqtt_client._eop_probe_open is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_opens_on_progress_when_the_last_layer_packet_is_missed(self, mqtt_client, caplog):
|
|
|
|
|
+ """The layer_num edge is a single transient packet and is dropped
|
|
|
|
|
+ intermittently (the reason #1867 needed a second mechanism). Progress
|
|
|
|
|
+ has to be able to open the window on its own."""
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"mc_percent": 99}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE open" in caplog.text
|
|
|
|
|
+ assert mqtt_client._eop_probe_open is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_zero_remaining_does_not_open_before_the_print_progresses(self, mqtt_client, caplog):
|
|
|
|
|
+ """mc_remaining_time reads 0 during pre-print calibration too, so it
|
|
|
|
|
+ only counts once progress is non-zero."""
|
|
|
|
|
+ mqtt_client.state.progress = 0.0
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"mc_remaining_time": 0, "mc_percent": 0}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE" not in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ def test_does_not_open_when_the_print_never_ran(self, mqtt_client, caplog):
|
|
|
|
|
+ """Bambuddy restarted mid-print, or firmware replayed a stale frame."""
|
|
|
|
|
+ mqtt_client._was_running = False
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE" not in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ def test_logs_only_changed_fields_after_opening(self, mqtt_client, caplog):
|
|
|
|
|
+ """Most probed fields are static across the window; logging all of
|
|
|
|
|
+ them every frame would bury the transitions we're looking for."""
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
|
|
|
|
|
+ caplog.clear()
|
|
|
|
|
+ # Identical frame — nothing moved, so nothing to say.
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
|
|
|
|
|
+ assert "EOP-PROBE" not in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 22}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "'stg_cur': 22" in caplog.text
|
|
|
|
|
+ assert "layer_num" not in caplog.text.split("EOP-PROBE")[-1]
|
|
|
|
|
+
|
|
|
|
|
+ def test_captures_the_fields_bambuddy_does_not_parse(self, mqtt_client, caplog):
|
|
|
|
|
+ """The whole point: mc_stage / mc_action / print_real_action are read
|
|
|
|
|
+ by nothing else in the codebase, so only the probe can show them."""
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100}})
|
|
|
|
|
+ mqtt_client._process_message(
|
|
|
|
|
+ {
|
|
|
|
|
+ "print": {
|
|
|
|
|
+ "mc_stage": 3,
|
|
|
|
|
+ "mc_action": 8,
|
|
|
|
|
+ "print_real_action": 2,
|
|
|
|
|
+ "print_gcode_action": 5,
|
|
|
|
|
+ "stg_cd": 1,
|
|
|
|
|
+ "home_flag": 2231371,
|
|
|
|
|
+ "spd_lvl": 0,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ for field in ("mc_stage", "mc_action", "print_real_action", "print_gcode_action", "stg_cd", "spd_lvl"):
|
|
|
|
|
+ assert field in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ def test_closes_on_finish_and_does_not_reopen(self, mqtt_client, caplog):
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100}})
|
|
|
|
|
+ mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE 2 CLOSE" in caplog.text
|
|
|
|
|
+ assert mqtt_client._eop_probe_open is False
|
|
|
|
|
+ assert mqtt_client._eop_probe_armed is False
|
|
|
|
|
+
|
|
|
|
|
+ caplog.clear()
|
|
|
|
|
+ # Firmware re-sending FINISH, or a stale replay, must not restart it.
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100, "mc_percent": 100}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE" not in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ def test_closing_frame_is_self_contained_when_nothing_changed(self, mqtt_client, caplog):
|
|
|
|
|
+ """A FINISH frame that repeats values already seen still has to log
|
|
|
|
|
+ something — otherwise the window has no visible end."""
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 100}})
|
|
|
|
|
+ caplog.clear()
|
|
|
|
|
+ mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
|
|
|
|
|
+ # Same value the probe already recorded on the opening frame.
|
|
|
|
|
+ mqtt_client._eop_probe_open = True
|
|
|
|
|
+ mqtt_client._eop_probe_armed = True
|
|
|
|
|
+ mqtt_client._eop_probe_last = {"gcode_state": "FINISH"}
|
|
|
|
|
+ mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "CLOSE" in caplog.text
|
|
|
|
|
+ assert "'gcode_state': 'FINISH'" in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ def test_rearms_for_the_next_print(self, mqtt_client, caplog):
|
|
|
|
|
+ # A completion callback is what lets _update_state finish the print
|
|
|
|
|
+ # (and clear _was_running), which the new-print detection depends on.
|
|
|
|
|
+ mqtt_client.on_print_complete = lambda data: None
|
|
|
|
|
+ mqtt_client.state.gcode_file = "current.3mf"
|
|
|
|
|
+ mqtt_client._previous_gcode_state = "RUNNING"
|
|
|
|
|
+
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100}})
|
|
|
|
|
+ mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
|
|
|
|
|
+ assert mqtt_client._eop_probe_armed is False
|
|
|
|
|
+
|
|
|
|
|
+ # New print: RUNNING again with a file, after the previous print
|
|
|
|
|
+ # completed. _update_state rearms the probe alongside the
|
|
|
|
|
+ # finish-photo one-shot.
|
|
|
|
|
+ mqtt_client._process_message(
|
|
|
|
|
+ {"print": {"gcode_state": "RUNNING", "gcode_file": "next.3mf", "subtask_name": "next"}}
|
|
|
|
|
+ )
|
|
|
|
|
+ assert mqtt_client._eop_probe_armed is True
|
|
|
|
|
+
|
|
|
|
|
+ caplog.clear()
|
|
|
|
|
+ mqtt_client.state.total_layers = 50
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 50}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE open" in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ def test_frame_budget_caps_output_but_still_logs_the_close(self, mqtt_client, caplog):
|
|
|
|
|
+ """A long final layer holds the window open at ~1 frame/second; the
|
|
|
|
|
+ user still has to be able to upload the resulting log."""
|
|
|
|
|
+ from backend.app.services.bambu_mqtt import _END_OF_PRINT_PROBE_MAX_FRAMES
|
|
|
|
|
+
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100}})
|
|
|
|
|
+ for i in range(_END_OF_PRINT_PROBE_MAX_FRAMES + 50):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"mc_remaining_time": i}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "frame budget" in caplog.text
|
|
|
|
|
+ caplog.clear()
|
|
|
|
|
+ mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "CLOSE" in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ def test_opens_on_numeric_strings(self, mqtt_client, caplog):
|
|
|
|
|
+ """Firmware sends these as ints or as numeric strings depending on
|
|
|
|
|
+ model and field, so the window checks must coerce rather than compare
|
|
|
|
|
+ a str against an int and silently never open."""
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": "100", "mc_percent": "99"}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "EOP-PROBE open" in caplog.text
|
|
|
|
|
+ assert mqtt_client._eop_probe_open is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_coercion_helper_falls_back_on_junk(self, mqtt_client):
|
|
|
|
|
+ """Unit-level, because feeding junk through _process_message would trip
|
|
|
|
|
+ the pre-existing parsers before ever reaching the probe. The guarantee
|
|
|
|
|
+ under test is only that the probe's own reads can't raise."""
|
|
|
|
|
+ assert mqtt_client._probe_number("100") == 100.0
|
|
|
|
|
+ assert mqtt_client._probe_number("not-a-number", 7) == 7
|
|
|
|
|
+ assert mqtt_client._probe_number(None) is None
|
|
|
|
|
+ assert mqtt_client._probe_number({"unexpected": "shape"}, 0) == 0
|
|
|
|
|
+
|
|
|
|
|
+ def test_probe_failure_cannot_break_ingest(self, mqtt_client, caplog, monkeypatch):
|
|
|
|
|
+ """Instrumentation must stay instrumentation: if the probe ever throws,
|
|
|
|
|
+ state parsing still has to complete."""
|
|
|
|
|
+
|
|
|
|
|
+ def boom(_data):
|
|
|
|
|
+ raise RuntimeError("probe exploded")
|
|
|
|
|
+
|
|
|
|
|
+ monkeypatch.setattr(mqtt_client, "_probe_end_of_print", boom)
|
|
|
|
|
+
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"gcode_state": "RUNNING", "layer_num": 100}})
|
|
|
|
|
+
|
|
|
|
|
+ assert mqtt_client.state.layer_num == 100
|
|
|
|
|
+ assert "EOP-PROBE failed" in caplog.text
|
|
|
|
|
+
|
|
|
|
|
+ def test_never_logs_the_access_code(self, mqtt_client, caplog):
|
|
|
|
|
+ with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
|
|
|
|
|
+ mqtt_client._process_message({"print": {"layer_num": 100}})
|
|
|
|
|
+ mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
|
|
|
|
|
+
|
|
|
|
|
+ probe_lines = [line for line in caplog.text.splitlines() if "EOP-PROBE" in line]
|
|
|
|
|
+ assert probe_lines
|
|
|
|
|
+ assert not any("12345678" in line for line in probe_lines)
|