Просмотр исходного кода

fix(safety): invert bed-jog Z direction on A1 / A1 Mini bed-slingers (#1334)

  On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
  control sent the nozzle straight into the build plate. Reporter
  triggered it with the 50 mm step and crashed their nozzle.

  Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
  where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
  so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
  gap). The frontend maps "Up" to negative distance with that convention
  in mind.

  A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
  firmware uses standard cartesian Z (Z+ = toolhead up). On those models
  G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
  classification at the bed-jog code path, so every printer got the same
  X1-convention G-code.

  Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
  existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
  already-defined A1_MODELS frozenset which covers display names and
  internal codes N1 / N2S). The bed-jog route now inverts the signed
  distance before emitting G-code when the printer model is in that set,
  so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
  regardless of which physical part moves. Frontend untouched, single
  source of truth lives in the backend, keyed off the Printer.model
  column. Route Query description and docstring updated to spell out the
  new contract: distance is the gap adjustment, not the raw Z value.
maziggy 1 неделя назад
Родитель
Сommit
a2c9eef87c

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 22 - 3
backend/app/api/routes/printers.py

@@ -2711,18 +2711,33 @@ async def set_chamber_light(
 async def bed_jog(
 async def bed_jog(
     printer_id: int,
     printer_id: int,
     distance: float = Query(
     distance: float = Query(
-        ..., description="Relative Z distance in mm (positive = bed down / nozzle further away, negative = bed up)"
+        ...,
+        description=(
+            "Signed nozzle-bed gap adjustment in mm. Negative = decrease gap "
+            '("up" arrow in the UI: bed up on bed-on-Z models, toolhead down '
+            "on A1 bed-slingers). Positive = increase gap. The backend "
+            "translates this into the right G-code Z sign per printer model."
+        ),
     ),
     ),
     force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
     force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Move the build plate along the Z axis by a relative distance.
+    """Adjust the nozzle-bed gap by a relative distance.
 
 
     Emits a short G-code sequence via MQTT. When ``force`` is true the soft
     Emits a short G-code sequence via MQTT. When ``force`` is true the soft
     endstops are disabled for the duration of the move, matching the
     endstops are disabled for the duration of the move, matching the
     "ignore and move anyway" option Bambu Studio offers when the printer
     "ignore and move anyway" option Bambu Studio offers when the printer
     is not homed.
     is not homed.
+
+    Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
+    is the Z-axis, and Bambu's home convention puts Z=0 at the top with
+    Z+ moving the bed down — so a frontend "Up" (decrease gap) maps
+    naturally to ``G1 Z-``. On bed-slingers (A1 / A1 Mini) the Z-axis is
+    the *toolhead*, and ``G1 Z-`` instead drives the nozzle DOWN into the
+    bed (#1334 reported exactly that crash). For those models we invert
+    the sign before emitting the G-code, so the UI semantics stay the
+    same regardless of which part physically moves.
     """
     """
     if distance == 0 or abs(distance) > 200:
     if distance == 0 or abs(distance) > 200:
         raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
         raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
@@ -2736,10 +2751,14 @@ async def bed_jog(
     if not client:
     if not client:
         raise HTTPException(400, "Printer not connected")
         raise HTTPException(400, "Printer not connected")
 
 
+    from backend.app.services.printer_manager import is_bed_slinger
+
+    gcode_distance = -distance if is_bed_slinger(printer.model) else distance
+
     lines = []
     lines = []
     if force:
     if force:
         lines.append("M211 S0")
         lines.append("M211 S0")
-    lines += ["G91", f"G1 Z{distance:.2f} F600", "G90"]
+    lines += ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
     if force:
     if force:
         lines.append("M211 S1")
         lines.append("M211 S1")
 
 

+ 18 - 0
backend/app/services/printer_manager.py

@@ -98,6 +98,24 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
     return model_upper in STG_CUR_IDLE_BUG_MODELS
     return model_upper in STG_CUR_IDLE_BUG_MODELS
 
 
 
 
+def is_bed_slinger(model: str | None) -> bool:
+    """Whether the printer's Z axis controls the *toolhead*, not the bed.
+
+    Bambu's A1 family (A1, A1 Mini; internal codes N1 / N2S) are open-frame
+    bed-slingers: the bed moves on Y, the toolhead moves on X+Z. On every
+    other current model (X1, P1, H2, H2C, H2D, H2S, P2S, ...) the bed moves
+    on Z and the toolhead is fixed in Z.
+
+    G-code direction is opposite on these two families. `G1 Z-10` reduces
+    the nozzle-bed gap on both, but on bed-on-Z machines it does so by
+    moving the BED up, while on bed-slingers it does so by moving the
+    TOOLHEAD down — which is what crashed the nozzle in #1334.
+    """
+    if not model:
+        return False
+    return model.strip().upper() in A1_MODELS
+
+
 # Minimum firmware versions for AMS drying support (confirmed via capture testing)
 # Minimum firmware versions for AMS drying support (confirmed via capture testing)
 # Keys are exact model names (upper-cased). Do NOT use substring matching — it would
 # Keys are exact model names (upper-cased). Do NOT use substring matching — it would
 # incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
 # incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").

+ 42 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -1143,6 +1143,48 @@ class TestSupportsChamberTemp:
         assert supports_chamber_temp("N1") is False
         assert supports_chamber_temp("N1") is False
 
 
 
 
+class TestIsBedSlinger:
+    """Tests for is_bed_slinger helper function (#1334)."""
+
+    def test_a1_series_is_bed_slinger(self):
+        """A1 / A1 Mini are open-frame bed-slingers — Z axis is the toolhead."""
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        assert is_bed_slinger("A1") is True
+        assert is_bed_slinger("A1 Mini") is True
+        assert is_bed_slinger("A1MINI") is True
+        assert is_bed_slinger("A1-MINI") is True
+
+    def test_a1_internal_codes_recognised(self):
+        """Internal MQTT/SSDP codes for A1 family must also classify as bed-slinger."""
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        # A1 Mini
+        assert is_bed_slinger("N1") is True
+        # A1
+        assert is_bed_slinger("N2S") is True
+
+    def test_bed_on_z_models_not_bed_slingers(self):
+        """X1 / P1 / H2 / H2C / H2D / H2S / P2S all have the bed on Z."""
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        for model in ("X1", "X1C", "X1E", "P1P", "P1S", "P2S", "H2C", "H2D", "H2DPRO", "H2S"):
+            assert is_bed_slinger(model) is False, f"{model} should NOT be classified as bed-slinger"
+
+    def test_none_model_returns_false(self):
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        assert is_bed_slinger(None) is False
+        assert is_bed_slinger("") is False
+
+    def test_case_insensitive(self):
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        assert is_bed_slinger("a1") is True
+        assert is_bed_slinger("a1 mini") is True
+        assert is_bed_slinger("x1c") is False
+
+
 class TestSupportsDrying:
 class TestSupportsDrying:
     """Tests for supports_drying helper function."""
     """Tests for supports_drying helper function."""
 
 

+ 56 - 0
backend/tests/unit/test_bed_jog.py

@@ -81,6 +81,62 @@ class TestBedJogAPI:
             assert lines[-1] == "M211 S1"
             assert lines[-1] == "M211 S1"
             assert "G1 Z-5.00" in sent_gcode
             assert "G1 Z-5.00" in sent_gcode
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2S", "H2C", "P2S"])
+    async def test_bed_jog_bed_on_z_models_pass_distance_through(
+        self, async_client: AsyncClient, printer_factory, model
+    ):
+        """On bed-on-Z printers the UI's signed distance maps directly to the
+        G-code Z value — UI "Up" (negative) → bed up (G1 Z-) → less gap."""
+        printer = await printer_factory(name=f"Test-{model}", model=model)
+        mock_client = MagicMock()
+        mock_client.send_gcode.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-10")
+            assert response.status_code == 200
+            sent_gcode = mock_client.send_gcode.call_args[0][0]
+            # Negative distance from the UI → negative Z in the G-code: bed moves up.
+            assert "G1 Z-10.00" in sent_gcode, f"{model}: expected G1 Z-10.00 in gcode, got {sent_gcode!r}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "model",
+        ["A1", "A1 Mini", "A1MINI", "A1-MINI", "N1", "N2S"],  # display names + internal codes
+    )
+    async def test_bed_jog_a1_models_invert_z_sign(self, async_client: AsyncClient, printer_factory, model):
+        """#1334 regression: on bed-slinger A1 / A1 Mini the Z axis is the
+        TOOLHEAD, not the bed. The frontend sends negative distance for "Up"
+        (decrease gap) expecting bed-on-Z semantics, but ``G1 Z-`` on A1
+        drives the nozzle DOWN into the bed. The backend must invert the
+        sign on these models so "Up" still decreases the gap by raising the
+        toolhead (G1 Z+) rather than crashing it."""
+        printer = await printer_factory(name=f"Test-{model}", model=model)
+        mock_client = MagicMock()
+        mock_client.send_gcode.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            # UI sends -10 for "Up" → backend must emit G1 Z+10 on A1.
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-10")
+            assert response.status_code == 200
+            sent_gcode = mock_client.send_gcode.call_args[0][0]
+            assert "G1 Z10.00" in sent_gcode, f"{model}: expected G1 Z10.00 in gcode, got {sent_gcode!r}"
+            assert "G1 Z-10" not in sent_gcode, f"{model}: must NOT emit negative Z for a UI 'Up' click"
+
+    @pytest.mark.asyncio
+    async def test_bed_jog_a1_down_arrow_drops_toolhead(self, async_client: AsyncClient, printer_factory):
+        """Symmetric to the regression test: UI "Down" (positive distance,
+        increase gap) on A1 must lower the toolhead via G1 Z-."""
+        printer = await printer_factory(name="A1-Mini-Test", model="A1 Mini")
+        mock_client = MagicMock()
+        mock_client.send_gcode.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
+            assert response.status_code == 200
+            sent_gcode = mock_client.send_gcode.call_args[0][0]
+            assert "G1 Z-10.00" in sent_gcode
+
 
 
 class TestHomeAxesAPI:
 class TestHomeAxesAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio

Некоторые файлы не были показаны из-за большого количества измененных файлов