fix(vp): emit FINISH after FTP upload so Print-flow slicers unwedge (#1280)

Bambuddy's VP supports two slicer flows: Send (file upload only — what
  queue/immediate/review modes are designed for) and Print (file upload
  + start-print, intended for proxy mode). When a user clicks Print
  against a non-proxy mode the VP must still respond gracefully — the
  file is fine to receive, just the start-print never happens. Instead
  the slicer wedged at "Downloading...(0%)" and blocked the next
  dispatch with "The printer is busy with another print job".

  Cause: on_file_received transitioned gcode_state PREPARE -> IDLE
  directly. Print-flow slicers watch the state cycle and only release
  their in-flight-job lock on PREPARE -> ... -> FINISH (or FAILED).
  PREPARE -> IDLE looks like "printer abandoned my job" and keeps the
  prior job pinned in the slicer's memory.

  Fix: transition PREPARE -> FINISH with prepare_percent=100. The 1-Hz
  periodic status push broadcasts the new state to every connected
  slicer within a second. Send-flow slicers don't watch this state so
  the change is a no-op for them; Print-flow slicers see the FINISH
  they were waiting for and unwedge.
This commit is contained in:
maziggy
2026-05-12 10:19:34 +02:00
parent 7596725550
commit 0d6171dc9a
3 changed files with 48 additions and 2 deletions
@@ -171,6 +171,41 @@ class TestVirtualPrinterInstance:
mock_archive.assert_called_once_with(file_path, "192.168.1.100")
@pytest.mark.asyncio
async def test_on_file_received_signals_FINISH_to_slicer(self, instance):
"""Regression #1280: when a slicer's Print flow uploads to a non-proxy VP,
the VP must transition gcode_state PREPARE → FINISH so the slicer's
in-flight-job lock releases. Going PREPARE → IDLE wedges Orca at
"Downloading...(0%)" and blocks the next dispatch with "busy with
another print job".
Send-flow slicers don't watch the post-upload state, so this is a
no-op behavior change for them.
"""
instance.mode = "immediate"
instance._mqtt = MagicMock()
instance._mqtt.set_gcode_state = MagicMock()
file_path = Path("/tmp/test.3mf") # nosec B108
with patch.object(instance, "_archive_file", new_callable=AsyncMock):
await instance.on_file_received(file_path, "192.168.1.100")
instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="test.3mf", prepare_percent="100")
@pytest.mark.asyncio
async def test_on_file_received_non_3mf_does_not_touch_state(self, instance):
"""Non-3MF uploads (e.g., a job's auxiliary files) must not transition
the visible state — the slicer is only tracking the .3mf upload."""
instance.mode = "immediate"
instance._mqtt = MagicMock()
instance._mqtt.set_gcode_state = MagicMock()
file_path = Path("/tmp/test.gcode") # nosec B108
with patch.object(instance, "_archive_file", new_callable=AsyncMock):
await instance.on_file_received(file_path, "192.168.1.100")
instance._mqtt.set_gcode_state.assert_not_called()
@pytest.mark.asyncio
async def test_archive_file_skips_non_3mf(self, instance):
"""Verify non-3MF files are skipped and cleaned up."""