Browse Source

fix(jog): stop disabling firmware endstops; warn that limits aren't enforced (#2579)

Manual jog could drive an axis past its travel limit into a collision.
Instrumenting the exact G-code to an H2D showed Bambuddy sending a clean
move at the limit (G91 / G1 Z-1.00 F600 / G90, no M211) that the printer
ran straight past, while its own touchscreen refuses the identical move.
This is a Bambu firmware bug: soft endstops are not enforced on G-code
received over MQTT, and no axis position is reported, so the move cannot
be clamped firmware- or client-side from position.

Two changes: (1) jogs no longer wrap moves in M211 S0/S1 — that disabled
the firmware's soft endstops globally, breaking even the touchscreen's
limits until a power cycle; a bare move keeps the touchscreen protected.
(2) The jog panel shows a prominent warning that travel limits are not
enforced during manual moves due to the firmware bug. Client-side
dead-reckoning enforcement is tracked separately.
maziggy 1 month ago
parent
commit
a6e7d671f2

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.5b2] - Unreleased
 ## [1.2.5b2] - Unreleased
 
 
 ### Fixed
 ### Fixed
+- **Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210)** — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit — `G91` / `G1 Z-1.00 F600` / `G90`, no endstop manipulation — that the printer executed straight past the stop, while the machine's **own touchscreen refuses the identical motion**. **This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT** (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves in `M211 S0`/`S1` — the old code disabled the firmware's soft endstops *globally* around every jog, which also broke the **touchscreen's** limits until the printer was power-cycled; it now sends a bare move and never touches `M211`, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are **not** enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled.
 - **External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien)** — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in `on_ams_change`, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (`vt_tray`/`vir_slot`), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (`remain`) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push.
 - **External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien)** — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in `on_ams_change`, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (`vt_tray`/`vir_slot`), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (`remain`) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push.
 - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo).
 - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo).
 - **Finish-photo capture held a DB connection open across the whole camera grab (#2572, reporter @Jostxxl)** — When a print finishes, the background finish-photo task reads a couple of rows (the capture setting, the printer, the archive) and then runs a capture pipeline that can take tens of seconds — timelapse last-frame extraction, waiting up to 20s for the stage-22 producer, an external-camera HTTP grab, or a fresh RTSP shot. It held one database session open across that entire pipeline, so a pooled connection sat `idle in transaction` for the full capture, once per finishing print — and finishes cluster on a farm. It now reads what it needs in a short session, releases the connection, runs the capture with no session held, and re-opens a fresh short session only to append the photo to the archive. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan) to stop holding sessions across slow I/O.
 - **Finish-photo capture held a DB connection open across the whole camera grab (#2572, reporter @Jostxxl)** — When a print finishes, the background finish-photo task reads a couple of rows (the capture setting, the printer, the archive) and then runs a capture pipeline that can take tens of seconds — timelapse last-frame extraction, waiting up to 20s for the stage-22 producer, an external-camera HTTP grab, or a fresh RTSP shot. It held one database session open across that entire pipeline, so a pooled connection sat `idle in transaction` for the full capture, once per finishing print — and finishes cluster on a farm. It now reads what it needs in a short session, releases the connection, runs the capture with no session held, and re-opens a fresh short session only to append the photo to the archive. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan) to stop holding sessions across slow I/O.

+ 25 - 11
backend/app/api/routes/printers.py

@@ -3126,16 +3126,29 @@ async def bed_jog(
             "translates this into the right G-code Z sign per printer model."
             "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)"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Adjust the nozzle-bed gap 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
-    endstops are disabled for the duration of the move, matching the
-    "ignore and move anyway" option Bambu Studio offers when the printer
-    is not homed.
+    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
     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
     is the Z-axis, and Bambu's home convention puts Z=0 at the top with
@@ -3162,12 +3175,10 @@ async def bed_jog(
 
 
     gcode_distance = -distance if is_bed_slinger(printer.model) else distance
     gcode_distance = -distance if is_bed_slinger(printer.model) else distance
 
 
-    lines = []
-    if force:
-        lines.append("M211 S0")
-    lines += ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
-    if force:
-        lines.append("M211 S1")
+    # 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"]
 
 
     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")
@@ -3202,6 +3213,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.
     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")
 
 

+ 3 - 1
backend/tests/integration/test_printers_api.py

@@ -3780,7 +3780,7 @@ class TestXYJogAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_success_x_only_emits_relative_gcode(self, async_client: AsyncClient, printer_factory):
     async def test_success_x_only_emits_relative_gcode(self, async_client: AsyncClient, printer_factory):
-        """X-only jog should emit G91/G90 wrapping and only include the X axis."""
+        """X-only jog is a bare relative move (no M211), wraps in G91/G90, X only."""
         printer = await printer_factory(name="P", model="X1C")
         printer = await printer_factory(name="P", model="X1C")
         mock_client = MagicMock()
         mock_client = MagicMock()
         mock_client.send_gcode.return_value = True
         mock_client.send_gcode.return_value = True
@@ -3789,6 +3789,8 @@ class TestXYJogAPI:
             response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
         assert response.status_code == 200
         assert response.status_code == 200
         sent = mock_client.send_gcode.call_args.args[0]
         sent = mock_client.send_gcode.call_args.args[0]
+        # Never touch M211 — bare move, exactly like the touchscreen (#2579).
+        assert "M211" not in sent
         assert sent.startswith("G91\n")
         assert sent.startswith("G91\n")
         assert sent.endswith("\nG90")
         assert sent.endswith("\nG90")
         assert "X10.00" in sent
         assert "X10.00" in sent

+ 17 - 15
backend/tests/unit/test_bed_jog.py

@@ -50,36 +50,38 @@ class TestBedJogAPI:
             assert response.status_code == 500
             assert response.status_code == 500
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_bed_jog_success_without_force(self, async_client: AsyncClient, printer_factory):
-        """When force=false the M211 guard lines must not be emitted."""
+    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)."""
         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
         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&force=false")
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
             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 "G91" in sent_gcode
-            assert "G1 Z10.00" in sent_gcode
-            assert "G90" in sent_gcode
-            assert "M211" not in sent_gcode
+            assert "M211" not in sent_gcode, f"must not touch M211, got: {sent_gcode!r}"
+            assert sent_gcode.splitlines() == ["G91", "G1 Z10.00 F600", "G90"]
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_bed_jog_success_with_force(self, async_client: AsyncClient, printer_factory):
-        """force=true must wrap the move in M211 S0 / M211 S1."""
-        printer = await printer_factory(name="P1")
+    async def test_bed_jog_never_touches_m211_even_with_stray_force(self, async_client: AsyncClient, printer_factory):
+        """#2579 core regression: the endpoint must NEVER emit any M211. A stray
+        ?force=true from an old client is ignored (FastAPI drops the unknown
+        param) and the move stays a bare relative move — no M211 S0 (the disable
+        that drove the nozzle into the bed) and no M211 S1 either.
+        """
+        printer = await printer_factory(name="H2C", model="H2C")
         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=-5&force=true")
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=50&force=true")
             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]
-            lines = sent_gcode.splitlines()
-            assert lines[0] == "M211 S0"
-            assert lines[-1] == "M211 S1"
-            assert "G1 Z-5.00" in sent_gcode
+            assert "M211" not in sent_gcode, f"must never touch M211, got: {sent_gcode!r}"
+            assert "G1 Z50.00" in sent_gcode
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2S", "H2C", "P2S"])
     @pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2S", "H2C", "P2S"])

+ 2 - 2
frontend/src/api/client.ts

@@ -3859,9 +3859,9 @@ export const api = {
     }),
     }),
 
 
   // Bed (Z-axis) jog
   // Bed (Z-axis) jog
-  bedJog: (printerId: number, distance: number, force: boolean = false) =>
+  bedJog: (printerId: number, distance: number) =>
     request<{ success: boolean; message: string }>(
     request<{ success: boolean; message: string }>(
-      `/printers/${printerId}/bed-jog?distance=${distance}&force=${force}`,
+      `/printers/${printerId}/bed-jog?distance=${distance}`,
       { method: 'POST' }
       { method: 'POST' }
     ),
     ),
   xyJog: (printerId: number, x: number, y: number) =>
   xyJog: (printerId: number, x: number, y: number) =>

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

@@ -422,6 +422,7 @@ export default {
       unload: 'Entladen',
       unload: 'Entladen',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: 'Verfahrwege werden bei manuellen Bewegungen nicht begrenzt – ein Firmware-Fehler von Bambu ignoriert die Software-Endschalter bei Remote-Befehlen. Bewegen Sie vorsichtig, um Kollisionen zu vermeiden.',
       title: 'Jog-Steuerung',
       title: 'Jog-Steuerung',
       bed: 'Bett',
       bed: 'Bett',
       step: 'Schritt (mm)',
       step: 'Schritt (mm)',

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

@@ -425,6 +425,7 @@ export default {
       unload: 'Unload',
       unload: 'Unload',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: 'Travel limits are not enforced during manual moves — a Bambu firmware bug ignores software endstops for remote commands. Move carefully to avoid a collision.',
       title: 'Jog Controls',
       title: 'Jog Controls',
       bed: 'Bed',
       bed: 'Bed',
       step: 'Step (mm)',
       step: 'Step (mm)',

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

@@ -422,6 +422,7 @@ export default {
       unload: 'Descargar',
       unload: 'Descargar',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: 'Los límites de recorrido no se aplican en los movimientos manuales: un error del firmware de Bambu ignora los finales de carrera por software en los comandos remotos. Muévelo con cuidado para evitar colisiones.',
       title: 'Controles de movimiento',
       title: 'Controles de movimiento',
       bed: 'Cama',
       bed: 'Cama',
       step: 'Paso (mm)',
       step: 'Paso (mm)',

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

@@ -422,6 +422,7 @@ export default {
       unload: 'Décharger',
       unload: 'Décharger',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: 'Les limites de déplacement ne sont pas appliquées lors des mouvements manuels : un bug du firmware Bambu ignore les butées logicielles pour les commandes à distance. Déplacez avec précaution pour éviter une collision.',
       title: 'Commandes de déplacement',
       title: 'Commandes de déplacement',
       bed: 'Plateau',
       bed: 'Plateau',
       step: 'Pas (mm)',
       step: 'Pas (mm)',

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

@@ -422,6 +422,7 @@ export default {
       unload: 'Scarica',
       unload: 'Scarica',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: 'I limiti di corsa non vengono applicati durante i movimenti manuali: un bug del firmware Bambu ignora i finecorsa software per i comandi remoti. Muovi con cautela per evitare collisioni.',
       title: 'Controlli jog',
       title: 'Controlli jog',
       bed: 'Piano',
       bed: 'Piano',
       step: 'Passo (mm)',
       step: 'Passo (mm)',

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

@@ -421,6 +421,7 @@ export default {
       unload: 'アンロード',
       unload: 'アンロード',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: '手動移動では可動範囲の制限が適用されません。Bambu のファームウェアの不具合により、リモートコマンドではソフトウェアリミットが無視されます。衝突しないよう注意して操作してください。',
       title: 'ジョグ操作',
       title: 'ジョグ操作',
       bed: 'ベッド',
       bed: 'ベッド',
       step: 'ステップ (mm)',
       step: 'ステップ (mm)',

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

@@ -392,6 +392,7 @@ export default {
       unload: '언로드'
       unload: '언로드'
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: '수동 이동 중에는 이동 한계가 적용되지 않습니다. Bambu 펌웨어 버그로 인해 원격 명령에서는 소프트웨어 엔드스톱이 무시됩니다. 충돌하지 않도록 주의해서 이동하세요.',
       title: '조그 컨트롤',
       title: '조그 컨트롤',
       bed: '베드',
       bed: '베드',
       step: '이동 거리 (mm)',
       step: '이동 거리 (mm)',

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

@@ -422,6 +422,7 @@ export default {
       unload: 'Descarregar',
       unload: 'Descarregar',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: 'Os limites de curso não são aplicados durante movimentos manuais — um bug do firmware da Bambu ignora os fins de curso por software em comandos remotos. Mova com cuidado para evitar colisões.',
       title: 'Controles de movimento',
       title: 'Controles de movimento',
       bed: 'Mesa',
       bed: 'Mesa',
       step: 'Passo (mm)',
       step: 'Passo (mm)',

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

@@ -422,6 +422,7 @@ export default {
       unload: 'Çıkar',
       unload: 'Çıkar',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: 'Manuel hareketlerde hareket sınırları uygulanmaz — bir Bambu donanım yazılımı hatası, uzaktan komutlarda yazılım limit anahtarlarını yok sayar. Çarpışmayı önlemek için dikkatlice hareket ettirin.',
       title: 'Jog kontrolleri',
       title: 'Jog kontrolleri',
       bed: 'Tabla',
       bed: 'Tabla',
       step: 'Adım (mm)',
       step: 'Adım (mm)',

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

@@ -422,6 +422,7 @@ export default {
       unload: '卸载',
       unload: '卸载',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: '手动移动时不会强制执行行程限位——Bambu 固件存在缺陷,远程指令会忽略软件限位。请小心移动以避免碰撞。',
       title: '点动控制',
       title: '点动控制',
       bed: '热床',
       bed: '热床',
       step: '步长 (mm)',
       step: '步长 (mm)',

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

@@ -422,6 +422,7 @@ export default {
       unload: '卸載',
       unload: '卸載',
     },
     },
     bedJog: {
     bedJog: {
+      limitWarning: '手動移動時不會強制執行行程限位——Bambu 韌體存在缺陷,遠端指令會忽略軟體限位。請小心移動以避免碰撞。',
       title: '點動控制',
       title: '點動控制',
       bed: '熱床',
       bed: '熱床',
       step: '步長 (mm)',
       step: '步長 (mm)',

+ 15 - 65
frontend/src/pages/PrintersPage.tsx

@@ -1835,7 +1835,6 @@ function PrinterCard({
   const [showBedJogMenu, setShowBedJogMenu] = useState<number | null>(null);
   const [showBedJogMenu, setShowBedJogMenu] = useState<number | null>(null);
   const [statusControlMenu, setStatusControlMenu] = useState<string | null>(null);
   const [statusControlMenu, setStatusControlMenu] = useState<string | null>(null);
   const [bedJogStep, setBedJogStep] = useState<number>(10);
   const [bedJogStep, setBedJogStep] = useState<number>(10);
-  const [showNotHomedModal, setShowNotHomedModal] = useState<null | { distance: number }>(null);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showUploadForPrint, setShowUploadForPrint] = useState(false);
   const [showUploadForPrint, setShowUploadForPrint] = useState(false);
@@ -2506,8 +2505,8 @@ function PrinterCard({
   });
   });
 
 
   const bedJogMutation = useMutation({
   const bedJogMutation = useMutation({
-    mutationFn: ({ distance, force }: { distance: number; force?: boolean }) =>
-      api.bedJog(printer.id, distance, force ?? false),
+    mutationFn: ({ distance }: { distance: number }) =>
+      api.bedJog(printer.id, distance),
     onError: (error: Error) =>
     onError: (error: Error) =>
       showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
       showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
   });
   });
@@ -2529,11 +2528,6 @@ function PrinterCard({
   const homeAxesMutation = useMutation({
   const homeAxesMutation = useMutation({
     mutationFn: (axes: 'z' | 'xy' | 'all') => api.homeAxes(printer.id, axes),
     mutationFn: (axes: 'z' | 'xy' | 'all') => api.homeAxes(printer.id, axes),
     onSuccess: () => {
     onSuccess: () => {
-      // Flip the session-scoped "warned" flag so the next bed-jog click doesn't re-prompt
-      // the not-homed modal. The flag is the same one "Move anyway" sets; after a successful
-      // auto-home request the printer is (or will shortly be) in a known-homed state, so
-      // prompting again in the same session is noise — #1052 follow-up.
-      try { sessionStorage.setItem(`bambuddy.bedJog.warned.${printer.id}`, '1'); } catch { /* ignore */ }
       showToast(t('printers.bedJog.homingStarted'));
       showToast(t('printers.bedJog.homingStarted'));
     },
     },
     onError: (error: Error) =>
     onError: (error: Error) =>
@@ -4150,16 +4144,10 @@ function PrinterCard({
                         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 requestZJog = (direction: 1 | -1) => {
                           const signed = direction * bedJogStep * (bambuIsPlateBelow ? 1 : -1);
                           const signed = direction * bedJogStep * (bambuIsPlateBelow ? 1 : -1);
-                          const warnedKey = `bambuddy.bedJog.warned.${printer.id}`;
-                          const warned = (() => {
-                            try { return sessionStorage.getItem(warnedKey) === '1'; }
-                            catch { return false; }
-                          })();
-                          if (warned) {
-                            bedJogMutation.mutate({ distance: signed, force: true });
-                          } else {
-                            setShowNotHomedModal({ distance: signed });
-                          }
+                          // 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 });
                         };
                         };
                         const requestXyJog = (x: number, y: number) => {
                         const requestXyJog = (x: number, y: number) => {
                           xyJogMutation.mutate({ x, y });
                           xyJogMutation.mutate({ x, y });
@@ -4189,6 +4177,15 @@ function PrinterCard({
                                     {t('printers.bedJog.title')}
                                     {t('printers.bedJog.title')}
                                   </div>
                                   </div>
                                   <div className="h-px bg-bambu-dark-tertiary" />
                                   <div className="h-px bg-bambu-dark-tertiary" />
+                                  {/* #2579: Bambu firmware does not enforce soft endstops on
+                                      G-code sent over MQTT, so manual moves can drive past the
+                                      travel limits and cause a collision. Not fixable from our
+                                      side — warn prominently. */}
+                                  <div className="flex items-start gap-1.5 bg-yellow-500/10 px-3 py-2 text-[11px] leading-snug text-yellow-700 dark:text-yellow-400">
+                                    <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
+                                    <span>{t('printers.bedJog.limitWarning')}</span>
+                                  </div>
+                                  <div className="h-px bg-bambu-dark-tertiary" />
                                   <div className="flex justify-center px-3 py-2.5">
                                   <div className="flex justify-center px-3 py-2.5">
                                     <div className="flex items-center justify-center gap-3">
                                     <div className="flex items-center justify-center gap-3">
                                     <div className="grid grid-cols-3 gap-1">
                                     <div className="grid grid-cols-3 gap-1">
@@ -6176,53 +6173,6 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
-      {/* Bed Jog — not-homed warning (Studio-style) */}
-      {showNotHomedModal && (
-        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
-          <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl w-full max-w-sm p-5">
-            <div className="flex items-start gap-3 mb-4">
-              <AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
-              <div>
-                <h3 className="text-sm font-semibold text-white mb-1">
-                  {t('printers.bedJog.notHomedTitle')}
-                </h3>
-                <p className="text-xs text-bambu-gray leading-relaxed">
-                  {t('printers.bedJog.notHomedMessage')}
-                </p>
-              </div>
-            </div>
-            <div className="flex flex-col gap-2">
-              <button
-                onClick={() => {
-                  homeAxesMutation.mutate('all');
-                  setShowNotHomedModal(null);
-                }}
-                className="w-full px-3 py-2 rounded-lg text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors"
-              >
-                {t('printers.bedJog.homeZ')}
-              </button>
-              <button
-                onClick={() => {
-                  const d = showNotHomedModal.distance;
-                  try { sessionStorage.setItem(`bambuddy.bedJog.warned.${printer.id}`, '1'); } catch { /* ignore */ }
-                  bedJogMutation.mutate({ distance: d, force: true });
-                  setShowNotHomedModal(null);
-                }}
-                className="w-full px-3 py-2 rounded-lg text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors"
-              >
-                {t('printers.bedJog.moveAnyway')}
-              </button>
-              <button
-                onClick={() => setShowNotHomedModal(null)}
-                className="w-full px-3 py-2 rounded-lg text-xs font-medium bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary transition-colors"
-              >
-                {t('common.cancel')}
-              </button>
-            </div>
-          </div>
-        </div>
-      )}
-
       {/* Skip Objects Modal */}
       {/* Skip Objects Modal */}
       <SkipObjectsModal
       <SkipObjectsModal
         printerId={printer.id}
         printerId={printer.id}

File diff suppressed because it is too large
+ 0 - 1
static/assets/index-4NXlsp1C.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CssD7OeM.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-UoLGEHs-.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- 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-C-4pTpBI.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-4NXlsp1C.css">
+    <script type="module" crossorigin src="/assets/index-CssD7OeM.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-UoLGEHs-.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff