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

fix(jog): send the nozzle-bed gap the API promises on every model (issue #1334)

POST /printers/{id}/bed-jog takes a signed nozzle-bed gap, documented since it
was written: positive asks for more room between the nozzle and the plate. On
an A1 it did the opposite. The reporter sent distance=5 for clearance and
watched the toolhead come down.

The sign had been flipped on A1 models since the original report on this issue,
where an A1 Mini owner clicked an arrow labelled "move the plate up" and watched
the nozzle dive. That is a labelling problem -- a bed-slinger's plate does not
move in Z at all, so closing the gap shows up as the toolhead descending -- and
it was solved in the transport layer, which turned a parameter documented as
model-independent into one that meant the opposite thing on part of the fleet.

Z is the nozzle-to-bed distance on every Bambu model, by definition of the
coordinate system rather than by convention: G1 Z+ opens the gap whether the bed
drops away from a fixed nozzle (X1/P1/H2, whose end G-code parks with
G1 Z{max_layer_z + 100}) or the nozzle rises off a fixed bed (A1/A2L). The
finish-photo plate restore already relies on exactly that and carries no model
branch. So distance goes onto the wire unchanged and one call means one physical
outcome everywhere: positive is the safe direction on every printer.

Which way an arrow points is a different question, about the machine in front of
the user rather than about G-code, so the printer card answers it and asks for
the gap it wants. The buttons move what you would expect them to move, exactly
as before; on a bed-slinger they now say toolhead rather than plate.

The A2L never had the old fix. It slings its bed the same way the A1 does, but
the inversion listed the A1 names and the A2L was not among them, so its up
arrow has been sending the toolhead at the plate for as long as the machine has
been supported. The new classifier also covers the alternate internal codes
A04 / A11 / A12, which LINEAR_RAIL_MODELS and SINGLE_NOZZLE_FLOW_MODELS both
carry and the old gate did not.

is_bed_slinger is gone from the backend rather than widened: with the route
model-independent it had no caller, and a kinematics helper sitting unused in
the service layer invites the next person to assume the backend handles
direction. It does not, deliberately.

Separately, the soft-endstop comments on both jog routes claimed the firmware
clamps a bare move at the travel limit. It does not, and #2579 measured that:
an H2D at its Z limit ran straight past a clean G91/G1 Z-1.00/G90, while its own
touchscreen refuses the identical move. What #2579 removed was M211 S0, which
disabled the limits globally and took the touchscreen's protection with them.
The jog popover has warned about this correctly the whole time; only the code
comments disagreed with it.
maziggy 23 часов назад
Родитель
Сommit
2c7c97c130

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 
 ### Fixed
 ### Fixed
+- **The jog API pushed an A1's nozzle at the plate when asked for clearance (#1334, reported by @AQU4R1U5)** — `POST /printers/{id}/bed-jog` takes a signed nozzle-bed gap: positive asks for more room between the nozzle and the plate. The reporter sent `distance=5` to his A1 and the toolhead came down instead. The endpoint had been flipping the sign on A1 models since the original report on this issue, where an A1 Mini owner clicked an arrow labelled "move the plate up" and watched the nozzle dive — but that flip was solving a labelling problem in the transport layer, and it turned a parameter documented as model-independent into one that meant the opposite thing on a quarter of the fleet. `Z` is the nozzle-to-bed distance on every Bambu model, whether the bed drops away from a fixed nozzle or the nozzle rises off a fixed bed, so the endpoint now sends `distance` through unchanged and one call means one physical outcome everywhere: positive is the safe direction on every printer. The arrows on the printer card are unchanged and still show you what you would expect to move — the card works out which gap change its own arrows stand for, which is where a question about the machine in front of you belongs. On an A1, A1 Mini or A2L those buttons now say *toolhead* rather than *plate*, since a bed-slinger's plate does not move in Z at all. **A2L owners had none of this**: the original fix listed the A1 models by name and the A2L, which slings its bed the same way, was never on the list — its up arrow has been sending the toolhead down since the machine was supported.
 - **A queue item pinned to one printer never said why it was waiting (#3074, reported by @Sawtaytoes)** — the reporter watched a job pinned to a specific X1C sit at Pending for fourteen minutes, with nothing in the UI or the API to say why, while that printer ran a print he had started from its own screen. The same job queued as "Any X1C" explains itself, because the model-based half of the scheduler builds a sentence for every way a job could not start and puts it on the row. The pinned half never wrote that field at all: the Home Assistant sensor interlock was its only writer, and it actively cleared the field whenever no sensor was holding the printer. Pinned items now say what they are waiting on in the same words — **Busy: X1C-01** while it prints, while it dries, or while another job on that printer goes first; **Offline, no Auto On smart plug: X1C-01** when nothing can switch it back on — and the row shows the purple **Waiting** badge instead of a silent Pending. "Finished, waiting for you to confirm the plate is clear" is split out from plain busy and named as itself, because it is the one case on that list that does not resolve on its own. A printer that is simply printing still sends no notification, exactly as before, and neither does a sensor interlock; only the cases that need you to go and do something do. On the queue timeline, a job that is merely next in line still draws its forecast bar as before, while one that is waiting for you now drops off it rather than promising a start time nobody can keep. Nothing about which job goes out, or when, has changed.
 - **A queue item pinned to one printer never said why it was waiting (#3074, reported by @Sawtaytoes)** — the reporter watched a job pinned to a specific X1C sit at Pending for fourteen minutes, with nothing in the UI or the API to say why, while that printer ran a print he had started from its own screen. The same job queued as "Any X1C" explains itself, because the model-based half of the scheduler builds a sentence for every way a job could not start and puts it on the row. The pinned half never wrote that field at all: the Home Assistant sensor interlock was its only writer, and it actively cleared the field whenever no sensor was holding the printer. Pinned items now say what they are waiting on in the same words — **Busy: X1C-01** while it prints, while it dries, or while another job on that printer goes first; **Offline, no Auto On smart plug: X1C-01** when nothing can switch it back on — and the row shows the purple **Waiting** badge instead of a silent Pending. "Finished, waiting for you to confirm the plate is clear" is split out from plain busy and named as itself, because it is the one case on that list that does not resolve on its own. A printer that is simply printing still sends no notification, exactly as before, and neither does a sensor interlock; only the cases that need you to go and do something do. On the queue timeline, a job that is merely next in line still draws its forecast bar as before, while one that is waiting for you now drops off it rather than promising a start time nobody can keep. Nothing about which job goes out, or when, has changed.
 - **A plate printed entirely from the external spool stalled at preheat and failed (#3087, reported by @Notaseraf)** — the reporter's P1S heated up, sat there for ten minutes and then paused with HMS 07FF_8012, "Failed to get AMS mapping table". Resuming just reheated it; prints that used the AMS were fine. The plate was a single filament out of a seven-filament MakerWorld project, mapped by hand to the external spool. A project's filaments are numbered across the whole project, so a plate that prints only the seventh one carries six placeholder entries in front of it — the same shape BambuStudio sends. Bambuddy read that as "nothing here is on the spool holder" and told the printer to use the AMS anyway, with a mapping that pointed at no tray at all, which is precisely the mapping table the firmware then could not find. A plate whose every printed filament sits on the external spool now dispatches with the AMS switched off, so it prints. The decision is made against the plate's own filament list rather than guessed from the mapping alone, because a placeholder entry and a filament that could not be matched to any tray look identical in the mapping, and sending the second one to the spool holder would silently print it in the wrong material (#2589). A plate that mixes the spool holder with an AMS tray, or that has a filament which matched nothing, is untouched and still behaves exactly as before, as are dual-nozzle printers — on those the same switch selects which nozzle to feed rather than whether to use the AMS.
 - **A plate printed entirely from the external spool stalled at preheat and failed (#3087, reported by @Notaseraf)** — the reporter's P1S heated up, sat there for ten minutes and then paused with HMS 07FF_8012, "Failed to get AMS mapping table". Resuming just reheated it; prints that used the AMS were fine. The plate was a single filament out of a seven-filament MakerWorld project, mapped by hand to the external spool. A project's filaments are numbered across the whole project, so a plate that prints only the seventh one carries six placeholder entries in front of it — the same shape BambuStudio sends. Bambuddy read that as "nothing here is on the spool holder" and told the printer to use the AMS anyway, with a mapping that pointed at no tray at all, which is precisely the mapping table the firmware then could not find. A plate whose every printed filament sits on the external spool now dispatches with the AMS switched off, so it prints. The decision is made against the plate's own filament list rather than guessed from the mapping alone, because a placeholder entry and a filament that could not be matched to any tray look identical in the mapping, and sending the second one to the spool holder would silently print it in the wrong material (#2589). A plate that mixes the spool holder with an AMS tray, or that has a filament which matched nothing, is untouched and still behaves exactly as before, as are dual-nozzle printers — on those the same switch selects which nozzle to feed rather than whether to use the AMS.
 - **A printer that had been offline for hours could stop the whole server (#3068, reported by @bazza2000)** — the reporter's A1 had been unreachable for 38 hours but still answered on its MQTT port, which is exactly the case the connection watchdog exists for: rebuild the session with a fresh client so nothing left over from the dead one can replay onto the new print. The rebuild ended in a call that waits for the old connection's network thread to finish, and that thread was stuck part-way through a TLS handshake the printer never completed, where nothing can interrupt it. The wait had no limit and it ran on the thread that serves every request, so Bambuddy stopped answering — the web UI, the API and the health check alike — while the process itself stayed up, which is why Docker's restart policy never kicked in. Retiring an old connection no longer waits for it: the replacement is built immediately and the old one is shut down in the background, cut off from the new session first so a printer that eventually answers cannot report itself connected again after being replaced. The same wait sat on three other printer paths — the queue's dispatch recovery, the staleness check behind an ordinary status poll, and editing, deleting or disconnecting a printer — and on the shutdown of the MQTT relay and smart-plug connections, where a wedged broker kept the process from exiting at all. All of them are fixed together. A connection that takes more than a few seconds to shut down now says so in the log instead of silently taking the server with it.
 - **A printer that had been offline for hours could stop the whole server (#3068, reported by @bazza2000)** — the reporter's A1 had been unreachable for 38 hours but still answered on its MQTT port, which is exactly the case the connection watchdog exists for: rebuild the session with a fresh client so nothing left over from the dead one can replay onto the new print. The rebuild ended in a call that waits for the old connection's network thread to finish, and that thread was stuck part-way through a TLS handshake the printer never completed, where nothing can interrupt it. The wait had no limit and it ran on the thread that serves every request, so Bambuddy stopped answering — the web UI, the API and the health check alike — while the process itself stayed up, which is why Docker's restart policy never kicked in. Retiring an old connection no longer waits for it: the replacement is built immediately and the old one is shut down in the background, cut off from the new session first so a printer that eventually answers cannot report itself connected again after being replaced. The same wait sat on three other printer paths — the queue's dispatch recovery, the staleness check behind an ordinary status poll, and editing, deleting or disconnecting a printer — and on the shutdown of the MQTT relay and smart-plug connections, where a wedged broker kept the process from exiting at all. All of them are fixed together. A connection that takes more than a few seconds to shut down now says so in the log instead of silently taking the server with it.

+ 56 - 40
backend/app/api/routes/printers.py

@@ -3852,10 +3852,12 @@ async def bed_jog(
     distance: float = Query(
     distance: float = Query(
         ...,
         ...,
         description=(
         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."
+            "Signed nozzle-bed gap adjustment in mm, identical on every model: "
+            "positive opens the gap (more clearance), negative closes it. Sent "
+            "to the printer as the G-code Z value unchanged — G-code Z is the "
+            "nozzle-to-bed distance whether the bed moves (X1 / P1 / H2) or the "
+            "toolhead does (A1 / A2L), so no per-model sign translation exists "
+            "or is needed."
         ),
         ),
     ),
     ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
@@ -3865,31 +3867,49 @@ async def bed_jog(
 
 
     Emits a short G-code sequence via MQTT.
     Emits a short G-code sequence via MQTT.
 
 
-    Soft-endstop policy (#2579). The printer's software travel limits are the
-    only thing between a jog button and a bed crash — on Bambu machines the
-    physical endstops are homing-only (there is no runtime limit switch in the
-    travel path), so once they are disabled nothing stops the move. The old
-    code disabled them (``M211 S0``) around every forced jog, and the UI sent
-    ``force`` on every jog, so the limits were off on every bed move — that is
-    what let a jog drive the nozzle into the bed on all models (#2579). This
-    endpoint now emits a **bare relative move and never touches ``M211`` at
-    all** — byte-for-byte what the printer's own touchscreen jog sends, which
-    stops at the travel limit. Bambuddy no longer disables the firmware's soft
-    endstops, and it no longer sends ``M211 S1`` either: that was an unverified
-    attempt to re-enable a printer left disabled by an older build, and on real
-    hardware the jog moved past the limit *with* it. If a printer still jogs
-    past its limits, its endstops were disabled at the firmware level by the old
-    build — power-cycle it once to restore them; from then on Bambuddy leaves
-    them alone.
-
-    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.
+    Soft-endstop policy (#2579). **Nothing clamps this move.** Bambu's firmware
+    does not enforce its soft endstops on G-code arriving over MQTT — measured
+    by logging the exact bytes to an H2D sitting at its Z limit: a clean
+    ``G91 / G1 Z-1.00 F600 / G90`` with no ``M211`` ran straight past, while the
+    printer's own touchscreen refuses the identical move, because the
+    touchscreen goes through the motion planner and ``gcode_line`` does not.
+    Push-status carries no axis position either, so there is nothing to clamp
+    against on this side. Treat every jog as unguarded; the jog popover says so
+    to the user, and a dead-reckoning clamp (track Z from a home, refuse
+    out-of-range moves) is the only real fix and is not built.
+
+    What Bambuddy stopped doing is making it worse. The old code wrapped every
+    move in ``M211 S0`` / ``M211 S1`` and the UI sent ``force`` on every jog, so
+    the limits came off on every bed move — and ``M211 S0`` disables them
+    *globally*, which broke the touchscreen's protection too until the printer
+    was power-cycled. That is the one genuine Bambuddy bug in #2579. This
+    endpoint now emits a bare relative move and never touches ``M211`` at all,
+    which leaves the touchscreen protected. It does not send ``M211 S1``
+    either: that was an unverified attempt to re-enable a printer an older
+    build had disabled, and on real hardware the jog moved past the limit
+    *with* it. A printer left in that state is recovered with one power cycle.
+
+    Direction (#1334, and the API half of it reported by @AQU4R1U5). ``Z``
+    is the nozzle-to-bed gap on every Bambu model, by definition of the
+    coordinate system rather than by convention: ``G1 Z+`` opens the gap
+    whether the bed drops away (X1 / P1 / H2, where Bambu's end G-code
+    parks with ``G1 Z{max_layer_z + 100}``) or the toolhead rises
+    (A1 / A1 Mini / A2L). The finish-photo plate restore relies on exactly
+    that and needs no model branch — see ``_restore_plate_for_finish_photo``.
+
+    So ``distance`` goes onto the wire unchanged, and one API call means one
+    physical thing on every printer: positive is always the safe direction.
+    This endpoint used to invert the sign on A1 models, which made a
+    documented model-independent parameter mean the opposite thing there —
+    ``distance=5``, asking for clearance, drove the toolhead at the plate.
+
+    What #1334 actually reported is a *label* problem, and it belongs to the
+    UI: the arrow says "move the plate up", and on a bed-slinger the plate
+    does not move in Z at all, so closing the gap shows up as the toolhead
+    diving. Which way an arrow points is a question about the machine in
+    front of the user, not about the G-code, so the printer card decides it
+    (``isBedSlinger`` in ``frontend/src/utils/bedSlinger.ts``) and sends the
+    gap it wants. Nothing here needs to know the model.
     """
     """
     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")
@@ -3903,14 +3923,10 @@ 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
-
-    # Bare relative move — exactly what the touchscreen sends. Never touch M211
-    # (#2579): the firmware keeps its soft endstops on by default and clamps the
-    # move at the travel limit.
-    lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
+    # Bare relative move, never M211 (#2579). Not because a bare move is safe —
+    # the firmware ignores soft endstops on MQTT G-code either way — but because
+    # M211 S0 disabled them globally, taking the touchscreen's limits with it.
+    lines = ["G91", f"G1 Z{distance:.2f} F600", "G90"]
 
 
     if not client.send_gcode("\n".join(lines)):
     if not client.send_gcode("\n".join(lines)):
         raise HTTPException(500, "Failed to send bed-jog command")
         raise HTTPException(500, "Failed to send bed-jog command")
@@ -3945,9 +3961,9 @@ async def xy_jog(
     if y:
     if y:
         axes.append(f"Y{y:.2f}")
         axes.append(f"Y{y:.2f}")
 
 
-    # Bare relative move — never touch M211 (#2579). The firmware keeps its soft
-    # endstops on by default and clamps the move at the travel limit; a printer
-    # left disabled by an older build is recovered with a power cycle.
+    # Bare relative move, never M211 (#2579) — see the bed-jog docstring. The
+    # firmware does not enforce soft endstops on MQTT G-code, so this move is
+    # unguarded; M211 S0 only widened that to the touchscreen as well.
     if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
     if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
         raise HTTPException(500, "Failed to send XY jog command")
         raise HTTPException(500, "Failed to send XY jog command")
 
 

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

@@ -187,24 +187,6 @@ 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").

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

@@ -1701,48 +1701,6 @@ 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."""
 
 

+ 67 - 40
backend/tests/unit/test_bed_jog.py

@@ -1,8 +1,14 @@
 """Unit tests for the bed-jog and home-axes endpoints (#791).
 """Unit tests for the bed-jog and home-axes endpoints (#791).
 
 
 Tests:
 Tests:
-  POST /api/v1/printers/{printer_id}/bed-jog?distance=<mm>&force=<bool>
+  POST /api/v1/printers/{printer_id}/bed-jog?distance=<mm>
   POST /api/v1/printers/{printer_id}/home-axes?axes=<z|xy|all>
   POST /api/v1/printers/{printer_id}/home-axes?axes=<z|xy|all>
+
+``distance`` is a signed nozzle-bed gap and ``axes`` is accepted but always
+homes everything — both endpoints once took a second parameter that made them
+do something more clever, and both parameters are gone for the same reason
+(#2579, #1052): on a machine with a nozzle and a plate, the clever version is
+the one that ends with them touching.
 """
 """
 
 
 from unittest.mock import MagicMock, patch
 from unittest.mock import MagicMock, patch
@@ -51,9 +57,12 @@ class TestBedJogAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_bed_jog_emits_bare_move_and_never_touches_m211(self, async_client: AsyncClient, printer_factory):
     async def test_bed_jog_emits_bare_move_and_never_touches_m211(self, async_client: AsyncClient, printer_factory):
-        """A jog must be a bare relative move — no M211 at all — exactly what the
-        printer's touchscreen sends, which the firmware clamps at the travel
-        limit. Touching M211 is what broke it (#2579)."""
+        """A jog must be a bare relative move — no M211 at all (#2579).
+
+        Not because a bare move is clamped: the firmware ignores soft endstops
+        on MQTT G-code whatever we send. But ``M211 S0`` disabled them
+        *globally*, so Bambuddy was also taking away the protection on the
+        printer's own touchscreen, and that part was ours to stop doing."""
         printer = await printer_factory(name="P1")
         printer = await printer_factory(name="P1")
         mock_client = MagicMock()
         mock_client = MagicMock()
         mock_client.send_gcode.return_value = True
         mock_client.send_gcode.return_value = True
@@ -83,61 +92,79 @@ class TestBedJogAPI:
             assert "M211" not in sent_gcode, f"must never touch M211, got: {sent_gcode!r}"
             assert "M211" not in sent_gcode, f"must never touch M211, got: {sent_gcode!r}"
             assert "G1 Z50.00" in sent_gcode
             assert "G1 Z50.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.asyncio
     @pytest.mark.parametrize(
     @pytest.mark.parametrize(
         "model",
         "model",
-        ["A1", "A1 Mini", "A1MINI", "A1-MINI", "N1", "N2S"],  # display names + internal codes
+        [
+            # bed-on-Z
+            "X1C",
+            "P1S",
+            "H2D",
+            "H2S",
+            "H2C",
+            "P2S",
+            # bed-slingers — the Z axis carries the toolhead instead
+            "A1",
+            "A1 Mini",
+            "A1MINI",
+            "A1-MINI",
+            "A2L",
+            "N1",
+            "N2S",
+            "N9",
+        ],
     )
     )
-    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."""
+    @pytest.mark.parametrize("distance", [-10, 10])
+    async def test_bed_jog_sends_the_distance_unchanged_on_every_model(
+        self, async_client: AsyncClient, printer_factory, model, distance
+    ):
+        """``distance`` is a nozzle-bed gap, and a gap is a gap on every printer.
+
+        ``G1 Z+`` opens the nozzle-bed gap whether the bed drops away from the
+        nozzle (X1 / P1 / H2) or the toolhead rises off the plate (A1 / A2L) —
+        that is what the Z axis *means*, not a per-family convention. So one
+        API call describes one physical outcome everywhere, and the route has
+        no model branch to get wrong.
+
+        It had one once. #1334 was a bed-slinger owner clicking an arrow
+        labelled "move the plate up" and watching the nozzle dive, and the fix
+        inverted the G-code sign on A1 models. That made a documented
+        model-independent parameter mean the opposite thing on those printers:
+        @AQU4R1U5 asked for 5 mm of clearance through the API and got 5 mm less.
+        """
         printer = await printer_factory(name=f"Test-{model}", model=model)
         printer = await printer_factory(name=f"Test-{model}", model=model)
         mock_client = MagicMock()
         mock_client = MagicMock()
         mock_client.send_gcode.return_value = True
         mock_client.send_gcode.return_value = True
         with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
         with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client
             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")
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance={distance}")
             assert response.status_code == 200
             assert response.status_code == 200
             sent_gcode = mock_client.send_gcode.call_args[0][0]
             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"
+            assert f"G1 Z{distance:.2f} F600" in sent_gcode, f"{model}: got {sent_gcode!r}"
 
 
     @pytest.mark.asyncio
     @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")
+    @pytest.mark.parametrize("model", ["A1", "A1 Mini", "A2L", "N1", "N2S", "N9"])
+    async def test_bed_jog_positive_is_the_safe_direction_on_bed_slingers(
+        self, async_client: AsyncClient, printer_factory, model
+    ):
+        """The one that bit @AQU4R1U5: asking for clearance must never close the gap.
+
+        Spelled out separately from the pass-through test above because this is
+        the property that matters to anyone driving the API from a script — the
+        sign of ``distance`` is the only thing standing between "lift the nozzle
+        off my print" and a nozzle in the plate, and it must not depend on which
+        printer is on the other end.
+        """
+        printer = await printer_factory(name=f"Test-{model}", model=model)
         mock_client = MagicMock()
         mock_client = MagicMock()
         mock_client.send_gcode.return_value = True
         mock_client.send_gcode.return_value = True
         with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
         with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client
             mock_pm.get_client.return_value = mock_client
-            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=5")
             assert response.status_code == 200
             assert response.status_code == 200
             sent_gcode = mock_client.send_gcode.call_args[0][0]
             sent_gcode = mock_client.send_gcode.call_args[0][0]
-            assert "G1 Z-10.00" in sent_gcode
+            assert "G1 Z-" not in sent_gcode, f"{model}: clearance request closed the gap — {sent_gcode!r}"
+            assert "G1 Z5.00" in sent_gcode
 
 
 
 
 class TestHomeAxesAPI:
 class TestHomeAxesAPI:

+ 123 - 0
frontend/src/__tests__/pages/PrintersPageBedJogDirection.test.tsx

@@ -0,0 +1,123 @@
+/**
+ * Which way the Z arrows move the printer (#1334).
+ *
+ * `POST /printers/{id}/bed-jog` takes a signed nozzle-bed gap that means one
+ * physical thing on every model, so the card is what decides which gap change
+ * an arrow stands for. On an X1 the plate rides the Z axis and "up" walks it
+ * toward the nozzle; on an A1 or A2L the plate is fixed in Z and "up" lifts
+ * the toolhead off it. Same arrow, opposite sign on the wire.
+ *
+ * The original report was an A1 Mini owner clicking "up" and watching the
+ * nozzle dive into the plate; the follow-up was an A1 owner asking the API
+ * for 5 mm of clearance and getting the same dive. These tests pin both ends.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const printer = (model: string) => [
+  {
+    id: 1,
+    name: `Test ${model}`,
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model,
+    enabled: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    auto_archive: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const idleStatus = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+/** Opens the movement popover and clicks one Z arrow; returns the distance sent. */
+async function clickZArrow(model: string, arrowLabel: string): Promise<number> {
+  const sent: number[] = [];
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json(printer(model))),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(idleStatus)),
+    http.post('/api/v1/printers/:id/bed-jog', ({ request }) => {
+      sent.push(Number(new URL(request.url).searchParams.get('distance')));
+      return HttpResponse.json({ success: true, message: 'ok' });
+    })
+  );
+
+  const user = userEvent.setup();
+  render(<PrintersPage />);
+
+  await user.click(await screen.findByTitle('Jog Controls'));
+  await user.click(await screen.findByLabelText(arrowLabel));
+  await waitFor(() => expect(sent).toHaveLength(1));
+  return sent[0];
+}
+
+describe('PrintersPage Z jog direction (#1334)', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([]))
+    );
+  });
+
+  describe('bed-on-Z printers, where the plate is what moves', () => {
+    it('sends a gap decrease when the plate is asked to go up', async () => {
+      expect(await clickZArrow('X1C', 'Move plate up')).toBeLessThan(0);
+    });
+
+    it('sends a gap increase when the plate is asked to go down', async () => {
+      expect(await clickZArrow('X1C', 'Move plate down')).toBeGreaterThan(0);
+    });
+  });
+
+  describe('bed-slingers, where the plate stays put and the toolhead moves', () => {
+    it.each(['A1', 'A1 Mini', 'A2L'])(
+      'opens the gap on %s when the toolhead is asked to go up',
+      async model => {
+        // The #1334 crash, from the UI side: this click used to send the
+        // nozzle down. Nothing about "up" may ever close the gap here.
+        expect(await clickZArrow(model, 'Move toolhead up')).toBeGreaterThan(0);
+      }
+    );
+
+    it.each(['A1', 'A1 Mini', 'A2L'])(
+      'closes the gap on %s when the toolhead is asked to go down',
+      async model => {
+        expect(await clickZArrow(model, 'Move toolhead down')).toBeLessThan(0);
+      }
+    );
+
+    it('does not offer the plate wording on a printer whose plate cannot move in Z', async () => {
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json(printer('A1 Mini'))),
+        http.get('/api/v1/printers/:id/status', () => HttpResponse.json(idleStatus))
+      );
+      const user = userEvent.setup();
+      render(<PrintersPage />);
+
+      await user.click(await screen.findByTitle('Jog Controls'));
+      await screen.findByLabelText('Move toolhead up');
+      expect(screen.queryByLabelText('Move plate up')).toBeNull();
+    });
+  });
+});

+ 70 - 0
frontend/src/__tests__/utils/bedSlinger.test.ts

@@ -0,0 +1,70 @@
+import { describe, it, expect } from 'vitest';
+import { isBedSlinger } from '../../utils/bedSlinger';
+
+describe('isBedSlinger', () => {
+  it.each([
+    'A1',
+    'A1 Mini',
+    'A1 mini',
+    'A1MINI',
+    'A1-MINI',
+    'a1 mini',
+    'A1M',
+    'A2L',
+    'a2l',
+    'N1',
+    'N2S',
+    'N9',
+    'A04',
+    'A11',
+    'A12',
+  ])('classifies %s as a bed-slinger', model => {
+    expect(isBedSlinger(model)).toBe(true);
+  });
+
+  it.each([
+    'X1',
+    'X1C',
+    'X1E',
+    'X2D',
+    'P1P',
+    'P1S',
+    'P2S',
+    'H2D',
+    'H2D Pro',
+    'H2C',
+    'H2S',
+    'C11',
+    'C12',
+    'C13',
+    'N6',
+    'N7',
+    'O1D',
+    'O1E',
+    'O2D',
+    'O1C',
+    'O1C2',
+    'O1S',
+  ])('classifies %s as bed-on-Z', model => {
+    expect(isBedSlinger(model)).toBe(false);
+  });
+
+  it('treats an unknown or missing model as bed-on-Z', () => {
+    // Bed-on-Z is what almost the whole fleet is, so it is the right default
+    // for a name we do not recognise. It is not a free choice though: nothing
+    // clamps a jog (#2579), so a misclassified bed-slinger sends its toolhead
+    // at the plate on the first click of "up". Unknown A-series names are the
+    // ones to watch when a new machine ships.
+    expect(isBedSlinger(null)).toBe(false);
+    expect(isBedSlinger(undefined)).toBe(false);
+    expect(isBedSlinger('')).toBe(false);
+    expect(isBedSlinger('Voron 2.4')).toBe(false);
+  });
+
+  it('does not sweep in other A-series names by prefix', () => {
+    // The list is explicit on purpose — a prefix match would claim every
+    // future "A<something>" before anyone has checked which way its Z goes.
+    expect(isBedSlinger('A3')).toBe(false);
+    expect(isBedSlinger('A1 Pro')).toBe(false);
+  });
+});

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -534,6 +534,8 @@ export default {
       step: 'Schritt (mm)',
       step: 'Schritt (mm)',
       up: 'Platte hoch',
       up: 'Platte hoch',
       down: 'Platte runter',
       down: 'Platte runter',
+      toolheadUp: 'Druckkopf hoch',
+      toolheadDown: 'Druckkopf runter',
       disabledWhilePrinting: 'Während des Drucks deaktiviert',
       disabledWhilePrinting: 'Während des Drucks deaktiviert',
       notHomedTitle: 'Drucker ist nicht referenziert',
       notHomedTitle: 'Drucker ist nicht referenziert',
       notHomedMessage: 'Der Drucker wurde seit dem letzten Druck nicht referenziert. Führen Sie zuerst die automatische Referenzfahrt aus (parkt den Werkzeugkopf und referenziert dann X, Y und Z) oder bewegen Sie trotzdem — die Software-Endschalter werden dabei umgangen.',
       notHomedMessage: 'Der Drucker wurde seit dem letzten Druck nicht referenziert. Führen Sie zuerst die automatische Referenzfahrt aus (parkt den Werkzeugkopf und referenziert dann X, Y und Z) oder bewegen Sie trotzdem — die Software-Endschalter werden dabei umgangen.',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -537,6 +537,8 @@ export default {
       step: 'Step (mm)',
       step: 'Step (mm)',
       up: 'Move plate up',
       up: 'Move plate up',
       down: 'Move plate down',
       down: 'Move plate down',
+      toolheadUp: 'Move toolhead up',
+      toolheadDown: 'Move toolhead down',
       disabledWhilePrinting: 'Disabled while printing',
       disabledWhilePrinting: 'Disabled while printing',
       notHomedTitle: 'Printer is not homed',
       notHomedTitle: 'Printer is not homed',
       notHomedMessage: 'The printer has not been homed since the last print. Run auto-home first for safe positioning (parks the toolhead, then homes X, Y, and Z), or move anyway — soft endstops will be bypassed.',
       notHomedMessage: 'The printer has not been homed since the last print. Run auto-home first for safe positioning (parks the toolhead, then homes X, Y, and Z), or move anyway — soft endstops will be bypassed.',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -534,6 +534,8 @@ export default {
       step: 'Paso (mm)',
       step: 'Paso (mm)',
       up: 'Subir la cama',
       up: 'Subir la cama',
       down: 'Bajar la cama',
       down: 'Bajar la cama',
+      toolheadUp: 'Subir el cabezal',
+      toolheadDown: 'Bajar el cabezal',
       disabledWhilePrinting: 'Desactivado durante la impresión',
       disabledWhilePrinting: 'Desactivado durante la impresión',
       notHomedTitle: 'La impresora no está en posición de origen',
       notHomedTitle: 'La impresora no está en posición de origen',
       notHomedMessage: 'La impresora no se ha llevado a su posición de origen desde la última impresión. Ejecute el autoorigen primero para un posicionamiento seguro (estaciona el cabezal y luego lleva X, Y y Z al origen), o mueva de todos modos — los finales de carrera por software se omitirán.',
       notHomedMessage: 'La impresora no se ha llevado a su posición de origen desde la última impresión. Ejecute el autoorigen primero para un posicionamiento seguro (estaciona el cabezal y luego lleva X, Y y Z al origen), o mueva de todos modos — los finales de carrera por software se omitirán.',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -534,6 +534,8 @@ export default {
       step: 'Pas (mm)',
       step: 'Pas (mm)',
       up: 'Monter le plateau',
       up: 'Monter le plateau',
       down: 'Descendre le plateau',
       down: 'Descendre le plateau',
+      toolheadUp: 'Monter la tête',
+      toolheadDown: 'Descendre la tête',
       disabledWhilePrinting: 'Désactivé pendant l\'impression',
       disabledWhilePrinting: 'Désactivé pendant l\'impression',
       notHomedTitle: 'Imprimante non référencée',
       notHomedTitle: 'Imprimante non référencée',
       notHomedMessage: 'L\'imprimante n\'a pas été référencée depuis la dernière impression. Lancez la référence automatique d\'abord pour un positionnement sûr (parque la tête d\'outil, puis référence X, Y et Z), ou déplacez quand même — les butées logicielles seront ignorées.',
       notHomedMessage: 'L\'imprimante n\'a pas été référencée depuis la dernière impression. Lancez la référence automatique d\'abord pour un positionnement sûr (parque la tête d\'outil, puis référence X, Y et Z), ou déplacez quand même — les butées logicielles seront ignorées.',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -534,6 +534,8 @@ export default {
       step: 'Passo (mm)',
       step: 'Passo (mm)',
       up: 'Sposta piano su',
       up: 'Sposta piano su',
       down: 'Sposta piano giù',
       down: 'Sposta piano giù',
+      toolheadUp: 'Sposta testina su',
+      toolheadDown: 'Sposta testina giù',
       disabledWhilePrinting: 'Disabilitato durante la stampa',
       disabledWhilePrinting: 'Disabilitato durante la stampa',
       notHomedTitle: 'Stampante non azzerata',
       notHomedTitle: 'Stampante non azzerata',
       notHomedMessage: 'La stampante non è stata azzerata dall\'ultima stampa. Esegui prima l\'azzeramento automatico per un posizionamento sicuro (parcheggia la testa di stampa, poi azzera X, Y e Z), oppure muovi comunque — i finecorsa software verranno ignorati.',
       notHomedMessage: 'La stampante non è stata azzerata dall\'ultima stampa. Esegui prima l\'azzeramento automatico per un posizionamento sicuro (parcheggia la testa di stampa, poi azzera X, Y e Z), oppure muovi comunque — i finecorsa software verranno ignorati.',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -533,6 +533,8 @@ export default {
       step: 'ステップ (mm)',
       step: 'ステップ (mm)',
       up: 'プレートを上へ',
       up: 'プレートを上へ',
       down: 'プレートを下へ',
       down: 'プレートを下へ',
+      toolheadUp: 'ツールヘッドを上へ',
+      toolheadDown: 'ツールヘッドを下へ',
       disabledWhilePrinting: '印刷中は無効',
       disabledWhilePrinting: '印刷中は無効',
       notHomedTitle: 'プリンターがホーミングされていません',
       notHomedTitle: 'プリンターがホーミングされていません',
       notHomedMessage: '前回の印刷以降、プリンターがホーミングされていません。安全な位置決めのためにまずオートホーミングを実行するか(ツールヘッドをパークしてからX・Y・Zをホーミングします)、このまま移動してください — ソフトエンドストップはバイパスされます。',
       notHomedMessage: '前回の印刷以降、プリンターがホーミングされていません。安全な位置決めのためにまずオートホーミングを実行するか(ツールヘッドをパークしてからX・Y・Zをホーミングします)、このまま移動してください — ソフトエンドストップはバイパスされます。',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -503,6 +503,8 @@ export default {
       step: '이동 거리 (mm)',
       step: '이동 거리 (mm)',
       up: '플레이트 위로',
       up: '플레이트 위로',
       down: '플레이트 아래로',
       down: '플레이트 아래로',
+      toolheadUp: '툴헤드 위로',
+      toolheadDown: '툴헤드 아래로',
       disabledWhilePrinting: '인쇄 중 비활성화됨',
       disabledWhilePrinting: '인쇄 중 비활성화됨',
       notHomedTitle: '프린터 홈 설정 필요',
       notHomedTitle: '프린터 홈 설정 필요',
       notHomedMessage: '마지막 인쇄 이후 홈 설정이 되지 않았습니다. 안전한 위치 지정을 위해 자동 홈 설정을 먼저 실행하거나, 그냥 이동하세요 — 소프트 엔드스톱이 무시됩니다.',
       notHomedMessage: '마지막 인쇄 이후 홈 설정이 되지 않았습니다. 안전한 위치 지정을 위해 자동 홈 설정을 먼저 실행하거나, 그냥 이동하세요 — 소프트 엔드스톱이 무시됩니다.',

+ 2 - 0
frontend/src/i18n/locales/nl.ts

@@ -537,6 +537,8 @@ export default {
       step: 'Stap (mm)',
       step: 'Stap (mm)',
       up: 'Plaat omhoog bewegen',
       up: 'Plaat omhoog bewegen',
       down: 'Plaat omlaag bewegen',
       down: 'Plaat omlaag bewegen',
+      toolheadUp: 'Printkop omhoog bewegen',
+      toolheadDown: 'Printkop omlaag bewegen',
       disabledWhilePrinting: 'Uitgeschakeld tijdens afdrukken',
       disabledWhilePrinting: 'Uitgeschakeld tijdens afdrukken',
       notHomedTitle: 'Printer is niet gehomed',
       notHomedTitle: 'Printer is niet gehomed',
       notHomedMessage: 'De printer is sinds de laatste afdruk niet gehomed. Voer eerst auto-home uit voor een veilige positionering (parkeert de toolhead en homet daarna X, Y en Z), of verplaats toch — soft endstops worden omzeild.',
       notHomedMessage: 'De printer is sinds de laatste afdruk niet gehomed. Voer eerst auto-home uit voor een veilige positionering (parkeert de toolhead en homet daarna X, Y en Z), of verplaats toch — soft endstops worden omzeild.',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -534,6 +534,8 @@ export default {
       step: 'Passo (mm)',
       step: 'Passo (mm)',
       up: 'Mover mesa para cima',
       up: 'Mover mesa para cima',
       down: 'Mover mesa para baixo',
       down: 'Mover mesa para baixo',
+      toolheadUp: 'Mover cabeçote para cima',
+      toolheadDown: 'Mover cabeçote para baixo',
       disabledWhilePrinting: 'Desativado durante a impressão',
       disabledWhilePrinting: 'Desativado durante a impressão',
       notHomedTitle: 'Impressora não referenciada',
       notHomedTitle: 'Impressora não referenciada',
       notHomedMessage: 'A impressora não foi referenciada desde a última impressão. Execute a referência automática primeiro para um posicionamento seguro (estaciona o cabeçote, depois referencia X, Y e Z), ou mova assim mesmo — os fins de curso de software serão ignorados.',
       notHomedMessage: 'A impressora não foi referenciada desde a última impressão. Execute a referência automática primeiro para um posicionamento seguro (estaciona o cabeçote, depois referencia X, Y e Z), ou mova assim mesmo — os fins de curso de software serão ignorados.',

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -508,6 +508,8 @@ export default {
       step: "Шаг (мм)",
       step: "Шаг (мм)",
       up: "Поднять стол",
       up: "Поднять стол",
       down: "Опустить стол",
       down: "Опустить стол",
+      toolheadUp: "Поднять печатающую голову",
+      toolheadDown: "Опустить печатающую голову",
       disabledWhilePrinting: "Недоступно во время печати",
       disabledWhilePrinting: "Недоступно во время печати",
       notHomedTitle: "Принтер не выполнил парковку по осям",
       notHomedTitle: "Принтер не выполнил парковку по осям",
       notHomedMessage: "После последней печати принтер не выполнял Home. Сначала запустите автоматическую парковку для безопасного позиционирования (парковка печатающей головы и поиск нуля по X, Y и Z) либо продолжите перемещение — программные концевики будут отключены.",
       notHomedMessage: "После последней печати принтер не выполнял Home. Сначала запустите автоматическую парковку для безопасного позиционирования (парковка печатающей головы и поиск нуля по X, Y и Z) либо продолжите перемещение — программные концевики будут отключены.",

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -534,6 +534,8 @@ export default {
       step: 'Adım (mm)',
       step: 'Adım (mm)',
       up: 'Plakayı yukarı hareket ettir',
       up: 'Plakayı yukarı hareket ettir',
       down: 'Plakayı aşağı hareket ettir',
       down: 'Plakayı aşağı hareket ettir',
+      toolheadUp: 'Baskı kafasını yukarı hareket ettir',
+      toolheadDown: 'Baskı kafasını aşağı hareket ettir',
       disabledWhilePrinting: 'Baskı sırasında devre dışı',
       disabledWhilePrinting: 'Baskı sırasında devre dışı',
       notHomedTitle: 'Yazıcı sıfırlanmamış',
       notHomedTitle: 'Yazıcı sıfırlanmamış',
       notHomedMessage: 'Yazıcı son baskıdan bu yana sıfırlanmadı. Güvenli konumlandırma için önce otomatik sıfırlamayı çalıştırın (kafayı park eder, ardından X, Y ve Z sıfırlanır) veya yine de hareket ettirin — yazılım son durakları atlanır.',
       notHomedMessage: 'Yazıcı son baskıdan bu yana sıfırlanmadı. Güvenli konumlandırma için önce otomatik sıfırlamayı çalıştırın (kafayı park eder, ardından X, Y ve Z sıfırlanır) veya yine de hareket ettirin — yazılım son durakları atlanır.',

+ 2 - 0
frontend/src/i18n/locales/uk.ts

@@ -537,6 +537,8 @@ export default {
       step: "Крок (мм)",
       step: "Крок (мм)",
       up: "Перемістити стіл угору",
       up: "Перемістити стіл угору",
       down: "Перемістити стіл униз",
       down: "Перемістити стіл униз",
+      toolheadUp: "Перемістити друкувальну голову вгору",
+      toolheadDown: "Перемістити друкувальну голову вниз",
       disabledWhilePrinting: "Вимкнено під час друку",
       disabledWhilePrinting: "Вимкнено під час друку",
       notHomedTitle: "Принтер не відкалібровано за початковою позицією",
       notHomedTitle: "Принтер не відкалібровано за початковою позицією",
       notHomedMessage: "Після останнього друку принтер не виконував пошук початкової позиції. Для безпечного позиціювання спочатку запустіть автопозиціювання: принтер припаркує друкувальну головку та визначить початкові координати X, Y і Z. Або продовжте переміщення без нього — програмні кінцеві обмежувачі буде обійдено.",
       notHomedMessage: "Після останнього друку принтер не виконував пошук початкової позиції. Для безпечного позиціювання спочатку запустіть автопозиціювання: принтер припаркує друкувальну головку та визначить початкові координати X, Y і Z. Або продовжте переміщення без нього — програмні кінцеві обмежувачі буде обійдено.",

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -534,6 +534,8 @@ export default {
       step: '步长 (mm)',
       step: '步长 (mm)',
       up: '热床上移',
       up: '热床上移',
       down: '热床下移',
       down: '热床下移',
+      toolheadUp: '喷头上移',
+      toolheadDown: '喷头下移',
       disabledWhilePrinting: '打印中已禁用',
       disabledWhilePrinting: '打印中已禁用',
       notHomedTitle: '打印机未归零',
       notHomedTitle: '打印机未归零',
       notHomedMessage: '打印机自上次打印以来尚未归零。请先执行自动归零以确保安全定位(先停放喷头,然后归零 X、Y 和 Z),或者直接移动 — 软限位将被绕过。',
       notHomedMessage: '打印机自上次打印以来尚未归零。请先执行自动归零以确保安全定位(先停放喷头,然后归零 X、Y 和 Z),或者直接移动 — 软限位将被绕过。',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -534,6 +534,8 @@ export default {
       step: '步長 (mm)',
       step: '步長 (mm)',
       up: '熱床上移',
       up: '熱床上移',
       down: '熱床下移',
       down: '熱床下移',
+      toolheadUp: '噴頭上移',
+      toolheadDown: '噴頭下移',
       disabledWhilePrinting: '列印中已停用',
       disabledWhilePrinting: '列印中已停用',
       notHomedTitle: '印表機未歸零',
       notHomedTitle: '印表機未歸零',
       notHomedMessage: '印表機自上次列印以來尚未歸零。請先執行自動歸零以確保安全定位(先停放噴頭,然後歸零 X、Y 和 Z),或者直接移動 — 軟限位將被繞過。',
       notHomedMessage: '印表機自上次列印以來尚未歸零。請先執行自動歸零以確保安全定位(先停放噴頭,然後歸零 X、Y 和 Z),或者直接移動 — 軟限位將被繞過。',

+ 18 - 11
frontend/src/pages/PrintersPage.tsx

@@ -2,6 +2,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
 import { createPortal } from 'react-dom';
 import { createPortal } from 'react-dom';
 import { compareFwVersions } from '../utils/firmwareVersion';
 import { compareFwVersions } from '../utils/firmwareVersion';
 import { formatPrintName } from '../utils/printName';
 import { formatPrintName } from '../utils/printName';
+import { isBedSlinger } from '../utils/bedSlinger';
 import { computePopoverPosition, type PopoverPosition } from '../utils/popoverPosition';
 import { computePopoverPosition, type PopoverPosition } from '../utils/popoverPosition';
 import {
 import {
   openCameraWindow,
   openCameraWindow,
@@ -4924,14 +4925,20 @@ function PrinterCard({
                       {(() => {
                       {(() => {
                         const canControl = hasPermission('printers:control');
                         const canControl = hasPermission('printers:control');
                         const disabled = isPrinting || !canControl;
                         const disabled = isPrinting || !canControl;
-                        const bambuIsPlateBelow = true; // positive Z moves plate away from nozzle
                         const jogButtonClass = 'flex h-8 w-8 items-center justify-center rounded bg-indigo-100 dark:bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 transition-colors hover:bg-indigo-200 dark:hover:bg-indigo-500/30 disabled:cursor-not-allowed disabled:opacity-50';
                         const jogButtonClass = 'flex h-8 w-8 items-center justify-center rounded bg-indigo-100 dark:bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 transition-colors hover:bg-indigo-200 dark:hover:bg-indigo-500/30 disabled:cursor-not-allowed disabled:opacity-50';
-                        const requestZJog = (direction: 1 | -1) => {
-                          const signed = direction * bedJogStep * (bambuIsPlateBelow ? 1 : -1);
-                          // The jog never disables the soft endstops (#2579), so it's always
-                          // safe: the firmware clamps the move at the travel limit, or refuses
-                          // it if the printer isn't homed. No not-homed bypass to gate.
-                          bedJogMutation.mutate({ distance: signed });
+                        // Which part the Z axis moves (#1334). The endpoint takes a signed
+                        // nozzle-bed gap that means the same thing on every printer, so the
+                        // arrows are ours to interpret: on a bed-slinger "up" lifts the
+                        // toolhead and opens the gap, on a bed-on-Z printer it raises the
+                        // plate toward the nozzle and closes it.
+                        const zMovesToolhead = isBedSlinger(printer.model);
+                        const requestZJog = (arrow: 'up' | 'down') => {
+                          const opensGap = zMovesToolhead ? arrow === 'up' : arrow === 'down';
+                          // No not-homed gate here, and no endstop bypass to gate either:
+                          // since #2579 the jog is a bare move that never touches M211. That
+                          // does not make it clamped — the firmware ignores soft endstops on
+                          // MQTT G-code entirely, which is what the banner above warns about.
+                          bedJogMutation.mutate({ distance: opensGap ? bedJogStep : -bedJogStep });
                         };
                         };
                         const requestXyJog = (x: number, y: number) => {
                         const requestXyJog = (x: number, y: number) => {
                           xyJogMutation.mutate({ x, y });
                           xyJogMutation.mutate({ x, y });
@@ -5023,10 +5030,10 @@ function PrinterCard({
                                     </div>
                                     </div>
                                     <div className="flex flex-col items-center gap-1">
                                     <div className="flex flex-col items-center gap-1">
                                       <button
                                       <button
-                                        onClick={() => requestZJog(-1)}
+                                        onClick={() => requestZJog('up')}
                                         disabled={bedJogMutation.isPending}
                                         disabled={bedJogMutation.isPending}
                                         className={jogButtonClass}
                                         className={jogButtonClass}
-                                        aria-label={t('printers.bedJog.up')}
+                                        aria-label={t(zMovesToolhead ? 'printers.bedJog.toolheadUp' : 'printers.bedJog.up')}
                                       >
                                       >
                                         <ArrowUp className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                                         <ArrowUp className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                                       </button>
                                       </button>
@@ -5034,10 +5041,10 @@ function PrinterCard({
                                         <Layers className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                                         <Layers className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                                       </div>
                                       </div>
                                       <button
                                       <button
-                                        onClick={() => requestZJog(1)}
+                                        onClick={() => requestZJog('down')}
                                         disabled={bedJogMutation.isPending}
                                         disabled={bedJogMutation.isPending}
                                         className={jogButtonClass}
                                         className={jogButtonClass}
-                                        aria-label={t('printers.bedJog.down')}
+                                        aria-label={t(zMovesToolhead ? 'printers.bedJog.toolheadDown' : 'printers.bedJog.down')}
                                       >
                                       >
                                         <ArrowDown className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                                         <ArrowDown className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                                       </button>
                                       </button>

+ 53 - 0
frontend/src/utils/bedSlinger.ts

@@ -0,0 +1,53 @@
+/**
+ * Which part of the printer the Z axis actually moves.
+ *
+ * This is a question about the machine standing in front of the user, not
+ * about G-code. `G1 Z+` opens the nozzle-bed gap on every Bambu model — that
+ * is what the axis means — so nothing on the wire depends on the answer, and
+ * `POST /printers/{id}/bed-jog` takes a signed gap that means one physical
+ * thing everywhere (see its docstring for the #1334 history).
+ *
+ * The answer is needed anyway, because a jog button carries an arrow, and an
+ * arrow promises the user a direction of *motion*. On an X1 / P1 / H2 the
+ * plate itself rides the Z axis, so "up" is the plate climbing toward the
+ * nozzle and the gap closing. On the A1 family and the A2L the plate only
+ * moves in Y; Z carries the toolhead, so "up" is the toolhead lifting off the
+ * plate and the gap opening. Same arrow, opposite gap.
+ *
+ * The model list mirrors the A-series entries in the backend registries
+ * (`LINEAR_RAIL_MODELS` / `SINGLE_NOZZLE_FLOW_MODELS` in
+ * `backend/app/utils/printer_models.py`, and `PRINTER_MODEL_ID_MAP` for the
+ * codes). A new bed-slinger has to be added here too.
+ */
+
+/**
+ * Models whose Z axis carries the toolhead, normalised (upper-case, letters
+ * and digits only) so display names, internal MQTT/SSDP codes and Bambu's
+ * terser cloud renames all land on the same entry.
+ *
+ * Kept as an explicit list rather than a prefix match: "A2L" and "A1" share a
+ * letter with nothing else today, but "A" would sweep in whatever the next
+ * A-series machine turns out to be, and the wrong answer here points an arrow
+ * at someone's plate.
+ */
+const BED_SLINGER_MODELS: ReadonlySet<string> = new Set([
+  // Display names
+  'A1',
+  'A1MINI',
+  'A2L',
+  // Bambu cloud short code for the A1 Mini (#1649)
+  'A1M',
+  // Internal MQTT / SSDP codes
+  'N1', // A1 Mini
+  'N2S', // A1
+  'N9', // A2L
+  'A04', // A1 Mini (alternate)
+  'A11', // A1
+  'A12', // A1 Mini
+]);
+
+/** True when this printer's Z axis moves the toolhead rather than the plate. */
+export function isBedSlinger(model: string | null | undefined): boolean {
+  if (!model) return false;
+  return BED_SLINGER_MODELS.has(model.toUpperCase().replace(/[^A-Z0-9]/g, ''));
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CqvxbvRs.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DmKOXvHy.js"></script>
+    <script type="module" crossorigin src="/assets/index-CqvxbvRs.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   </head>
   <body>
   <body>

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