diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c81d9ce..a5b8e8ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.3.1] - 2026-04-20 ### Fixed +- **⚠️ Bed-Jog "Home Z" Could Crash the Bed Into the Toolhead** ([#1052](https://github.com/maziggy/bambuddy/issues/1052)) — **Critical safety fix.** On H2C (and by extension any Bambu printer where Z-home moves the bed UP toward an endstop — H2D, H2S, and X1 family all share this kinematics) the bed-jog modal's "Home Z" button sent a raw `G28 Z` over the `gcode_line` MQTT command. Bare `G28 Z` skips the toolhead-park step that a full `G28` runs first, so the bed raised without stopping at a safe height — in the reporter's case the toolhead happened to be parked on the purge chute and no damage was caused, but hitting the button with a toolhead anywhere else would have driven the bed into it at full Z speed. Root cause was the `/api/v1/printers/{id}/home-axes` endpoint's per-axis gcode mapping (`"z" → "G28 Z"`, `"xy" → "G28 X Y"`, `"all" → "G28"`). The endpoint now ignores the `axes` argument entirely and always sends a bare `G28`, which Bambu firmware expands into the safe multi-step sequence (park toolhead → home XY → home Z). The MQTT client helper `BambuClient.home_axes()` has the same change. The bed-jog modal is retitled "Auto Home" and its copy now says "parks the toolhead, then homes X, Y, and Z" so users aren't surprised when X/Y motion happens first. Regression test covers all three axes arguments producing the same bare `G28`. Thanks to @mikefromdot for catching this with an undamaged retest. - **P1S Print Dispatches Stuck at IDLE Due to task_id Int32 Overflow** ([#1042](https://github.com/maziggy/bambuddy/issues/1042)) — Since the #1011 fix switched `project_id` / `subtask_id` / `task_id` from hardcoded `"0"` to `str(int(time.time() * 1000))`, each submission sent a 13-digit epoch-millisecond value (~1.7×10¹²). P1S firmware (observed on 01.10.00.00) clamps oversized task identity fields to signed int32 max (`2147483647`), so every dispatch looked identical from the printer's perspective — it treated a fresh print as a continuation of the prior FAILED job, returned `result: success` for `project_file` (command accepted), but then sat at `gcode_state: IDLE` with an empty `gcode_file` instead of transitioning to `PREPARE`/`RUNNING`. Thanks to @EdwardChamberlain for pinpointing the exact line and suggesting the mod fix. The three identity fields are now set to `str(int(time.time() * 1000) % 2_147_483_647 or 1)`: modulo keeps values inside the signed-int31 window with a ~24-day uniqueness cycle (more than enough for reprint deduplication), and `or 1` guards against the astronomically unlikely zero case (the printer rejects `task_id=0`). Regression test `test_submission_id_fits_signed_int32` asserts all three IDs are `< 2**31`. Two of @EdwardChamberlain's other suggestions — resolving `bed_type` from the sliced 3MF's per-plate JSON instead of hardcoding `"auto"`, and gating dispatch success on an actual state transition to `PREPARE`/`RUNNING` rather than on `project_file`'s `result: success` — are larger changes tracked separately. - **FTP Download Zombie-Thread Race on Slow WiFi** ([#1014](https://github.com/maziggy/bambuddy/issues/1014)) — Users on 2.4 GHz WiFi with heavy neighborhood interference saw "Successfully downloaded" log lines for queued prints that Bambuddy nonetheless reported as failed, and the slicer file landed in `/app/data/archives/temp/` with the File Manager unable to find it. Root cause: `download_file_async` wrapped the blocking FTP `RETR` in `asyncio.wait_for` with a 30–60 s timeout (user-configurable via `ftp_timeout`), but the wrapped thread couldn't be cancelled. On a slow link the download would overshoot the timeout by 15–30 s, at which point `_run()` waited a hard-coded 0.5 s for the zombie to finish, gave up, and returned failure — which triggered `with_ftp_retry` attempt 2, whose `_download` spawned a brand-new FTP session that contended with attempt 1's still-running transfer. Attempt 1's zombie eventually completed and wrote the file to disk, but by then attempt 2 (and 3, 4) had long since run out their own timeouts with their own fresh `completion` dicts and reported failure; the archive pipeline saw only the final `None` from `with_ftp_retry` and created a fallback archive row with no 3MF data, which is why Skip-Object couldn't find the plate's objects even though the 3MF was on disk. Two fixes: the 0.5 s post-timeout sleep is replaced with a `threading.Event` the worker sets in its `finally` block, and `_run()` waits for that event with a bounded grace of `max(min(ftp_timeout, 30), 0.5)` s — covering the slow-WiFi overshoot case without extending a genuinely stuck connection indefinitely. The log line now includes the grace window (`timed out after Xs (plus Ys grace)`). Regression test `test_download_file_async_timeout_waits_for_slow_zombie` simulates a 1.5 s zombie with a 1.0 s wait_for timeout; old 0.5 s sleep would give up, new 1.0 s grace salvages. The existing `test_download_file_async_timeout_no_salvage_when_incomplete` still passes — a thread that never completes within the grace window still returns failure. Thanks to @heffe2001 for the detailed reproduction and support logs. - **Obico: Cold-Start Capture Timeout Sticks in Status Banner** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — On the very first detection poll after a restart, the initial RTSP snapshot capture occasionally exceeded the 20 s `SNAPSHOT_CAPTURE_TIMEOUT` (the first keyframe from the printer's camera can take a while on a cold RTSP connection). Subsequent polls every ~8 s recovered and captured in ~1.2 s, but the red `× Failed to capture snapshot for printer N` banner in Settings → Failure Detection → Status stayed up forever because `ObicoDetectionService._last_error` was written on failure and never cleared on the next successful poll. The successful branch in `_check_printer` now clears `_last_error` to `None` once a capture + ML call + classification complete, so the banner reflects only errors from recent cycles. Configuration-level errors (missing `external_url`, missing `ml_url`) still persist because they return before the clearing line — users still see them until they fix the setting. Regression test covers: seed `_last_error`, run one successful `_check_printer`, assert `_last_error is None`. Thanks to @fblix for the reproduction and screenshot. diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 09797643f..6d79b9897 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -2497,19 +2497,30 @@ async def bed_jog( @router.post("/{printer_id}/home-axes") async def home_axes( printer_id: int, - axes: str = Query("z", description="Axes to home: 'z', 'xy', or 'all'"), + axes: str = Query( + "all", + description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.", + ), _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): - """Home one or more axes via G28.""" + """Run the printer's full auto-home sequence via bare `G28`. + + Bambu printers (H2C / H2D / H2S / X1 family) home the Z axis by moving + the BED UP toward an endstop at the top of travel. If the toolhead is + not already parked out of the way, a bare `G28 Z` will crash the bed + into the toolhead — #1052 reported exactly that on H2C: the bed rose + without stopping at a safe height because `G28 Z` skipped the + toolhead-park step that a full `G28` runs first. + + The endpoint therefore ignores the `axes` argument and always sends a + bare `G28`, which the firmware expands into a safe multi-step sequence + (park toolhead → home XY → home Z). The argument is kept only for + backward-compat with existing clients; sending an invalid value still + returns 400 so typos surface instead of silently proceeding. + """ axes = axes.lower() - if axes == "z": - gcode = "G28 Z" - elif axes == "xy": - gcode = "G28 X Y" - elif axes == "all": - gcode = "G28" - else: + if axes not in ("z", "xy", "all"): raise HTTPException(400, "axes must be 'z', 'xy', or 'all'") result = await db.execute(select(Printer).where(Printer.id == printer_id)) @@ -2521,10 +2532,10 @@ async def home_axes( if not client: raise HTTPException(400, "Printer not connected") - if not client.send_gcode(gcode): + if not client.send_gcode("G28"): raise HTTPException(500, "Failed to send home command") - return {"success": True, "message": f"Home {axes} command sent"} + return {"success": True, "message": "Full auto-home sequence sent"} @router.post("/{printer_id}/hms/clear") diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index e11a2787e..0b25c02be 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -4079,17 +4079,15 @@ class BambuMQTTClient: return True def home_axes(self, axes: str = "XYZ") -> bool: - """Home the specified axes. + """Run the printer's full auto-home sequence. - Args: - axes: Axes to home (e.g., "XYZ", "X", "XY", "Z") - - Returns: - True if command was sent, False otherwise + The ``axes`` argument is ignored: a bare ``G28`` is always sent so + Bambu firmware runs its safe multi-step routine (park toolhead → + home XY → home Z). Partial-axis variants like ``G28 Z`` skip the + toolhead-park step and can crash the bed into the toolhead on H2C + / H2D / H2S / X1 where Z-home moves the bed UP — see #1052. """ - # G28 homes all axes, G28 X Y Z homes specific axes - axes_param = " ".join(axes.upper()) - return self.send_gcode(f"G28 {axes_param}") + return self.send_gcode("G28") def move_axis(self, axis: str, distance: float, speed: int = 3000) -> bool: """Move an axis by a relative distance. diff --git a/backend/tests/unit/test_bed_jog.py b/backend/tests/unit/test_bed_jog.py index f611d7333..b4b5a2710 100644 --- a/backend/tests/unit/test_bed_jog.py +++ b/backend/tests/unit/test_bed_jog.py @@ -95,11 +95,11 @@ class TestHomeAxesAPI: assert response.status_code == 400 @pytest.mark.asyncio - @pytest.mark.parametrize( - "axes,expected", - [("z", "G28 Z"), ("xy", "G28 X Y"), ("all", "G28")], - ) - async def test_home_axes_success(self, async_client: AsyncClient, printer_factory, axes, expected): + @pytest.mark.parametrize("axes", ["z", "xy", "all"]) + async def test_home_axes_always_runs_full_home(self, async_client: AsyncClient, printer_factory, axes): + # Regression for #1052: regardless of the axes argument, the endpoint must send a bare + # `G28` so the printer's safe auto-home sequence (toolhead park → XY home → Z home) runs. + # Sending `G28 Z` alone on H2C/H2D/H2S/X1 can crash the bed into the toolhead. printer = await printer_factory(name="P1") mock_client = MagicMock() mock_client.send_gcode.return_value = True @@ -107,7 +107,7 @@ class TestHomeAxesAPI: mock_pm.get_client.return_value = mock_client response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes={axes}") assert response.status_code == 200 - mock_client.send_gcode.assert_called_once_with(expected) + mock_client.send_gcode.assert_called_once_with("G28") @pytest.mark.asyncio async def test_home_axes_not_connected(self, async_client: AsyncClient, printer_factory): diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index fa26daa82..8d944cfa4 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -341,10 +341,10 @@ export default { down: 'Platte runter', disabledWhilePrinting: 'Während des Drucks deaktiviert', notHomedTitle: 'Drucker ist nicht referenziert', - notHomedMessage: 'Die Z-Achse wurde seit dem letzten Druck nicht referenziert. Referenzieren Sie Z zuerst für eine sichere Positionierung oder bewegen Sie trotzdem — die Software-Endschalter werden dabei umgangen.', - homeZ: 'Z referenzieren', + 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.', + homeZ: 'Automatische Referenzfahrt', moveAnyway: 'Trotzdem bewegen', - homingStarted: 'Z-Achse wird referenziert…', + homingStarted: 'Drucker wird automatisch referenziert…', }, // Permissions permission: { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f52a50403..aedde836a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -341,10 +341,10 @@ export default { down: 'Move plate down', disabledWhilePrinting: 'Disabled while printing', notHomedTitle: 'Printer is not homed', - notHomedMessage: 'The Z axis has not been homed since the last print. Home Z first for safe positioning, or move anyway — soft endstops will be bypassed.', - homeZ: 'Home Z', + 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.', + homeZ: 'Auto Home', moveAnyway: 'Move anyway', - homingStarted: 'Homing Z axis…', + homingStarted: 'Auto-homing printer…', }, // Permissions permission: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 8c0d78e27..5ab2f2abb 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -341,10 +341,10 @@ export default { down: 'Descendre le plateau', disabledWhilePrinting: 'Désactivé pendant l\'impression', notHomedTitle: 'Imprimante non référencée', - notHomedMessage: 'L\'axe Z n\'a pas été référencé depuis la dernière impression. Référencez Z d\'abord pour un positionnement sûr, ou déplacez quand même — les butées logicielles seront ignorées.', - homeZ: 'Référencer Z', + 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.', + homeZ: 'Référence automatique', moveAnyway: 'Déplacer quand même', - homingStarted: 'Référencement de l\'axe Z…', + homingStarted: 'Référencement automatique en cours…', }, // Permissions permission: { diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 3561da716..388bd945f 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -341,10 +341,10 @@ export default { down: 'Sposta piano giù', disabledWhilePrinting: 'Disabilitato durante la stampa', notHomedTitle: 'Stampante non azzerata', - notHomedMessage: 'L\'asse Z non è stato azzerato dall\'ultima stampa. Azzera Z prima per un posizionamento sicuro, oppure muovi comunque — i finecorsa software verranno ignorati.', - homeZ: 'Azzera Z', + 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.', + homeZ: 'Azzeramento automatico', moveAnyway: 'Muovi comunque', - homingStarted: 'Azzeramento asse Z…', + homingStarted: 'Azzeramento automatico in corso…', }, // Permissions permission: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index d4255d70b..ac4d0b1b3 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -340,10 +340,10 @@ export default { down: 'プレートを下へ', disabledWhilePrinting: '印刷中は無効', notHomedTitle: 'プリンターがホーミングされていません', - notHomedMessage: '前回の印刷以降、Z軸がホーミングされていません。安全な位置決めのためにまずZをホーミングするか、このまま移動してください — ソフトエンドストップはバイパスされます。', - homeZ: 'Zをホーミング', + notHomedMessage: '前回の印刷以降、プリンターがホーミングされていません。安全な位置決めのためにまずオートホーミングを実行するか(ツールヘッドをパークしてからX・Y・Zをホーミングします)、このまま移動してください — ソフトエンドストップはバイパスされます。', + homeZ: 'オートホーミング', moveAnyway: 'このまま移動', - homingStarted: 'Z軸をホーミング中…', + homingStarted: 'プリンターをオートホーミング中…', }, // Permissions permission: { diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 5b0530162..fd66f7c2e 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -341,10 +341,10 @@ export default { down: 'Mover mesa para baixo', disabledWhilePrinting: 'Desativado durante a impressão', notHomedTitle: 'Impressora não referenciada', - notHomedMessage: 'O eixo Z não foi referenciado desde a última impressão. Referencie Z primeiro para um posicionamento seguro, ou mova assim mesmo — os fins de curso de software serão ignorados.', - homeZ: 'Referenciar Z', + 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.', + homeZ: 'Referência automática', moveAnyway: 'Mover assim mesmo', - homingStarted: 'Referenciando eixo Z…', + homingStarted: 'Referenciando impressora automaticamente…', }, // Permissions permission: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index a691be534..6dbdf7d62 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -341,10 +341,10 @@ export default { down: '热床下移', disabledWhilePrinting: '打印中已禁用', notHomedTitle: '打印机未归零', - notHomedMessage: '自上次打印以来 Z 轴尚未归零。请先归零 Z 以确保安全定位,或者直接移动 — 软限位将被绕过。', - homeZ: '归零 Z', + notHomedMessage: '打印机自上次打印以来尚未归零。请先执行自动归零以确保安全定位(先停放喷头,然后归零 X、Y 和 Z),或者直接移动 — 软限位将被绕过。', + homeZ: '自动归零', moveAnyway: '强制移动', - homingStarted: 'Z 轴归零中…', + homingStarted: '打印机自动归零中…', }, // Permissions permission: { diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a2b027d5f..ea8d9f8d3 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -341,10 +341,10 @@ export default { down: '熱床下移', disabledWhilePrinting: '列印中已停用', notHomedTitle: '印表機未歸零', - notHomedMessage: '自上次列印以來 Z 軸尚未歸零。請先歸零 Z 以確保安全定位,或者直接移動 — 軟限位將被繞過。', - homeZ: '歸零 Z', + notHomedMessage: '印表機自上次列印以來尚未歸零。請先執行自動歸零以確保安全定位(先停放噴頭,然後歸零 X、Y 和 Z),或者直接移動 — 軟限位將被繞過。', + homeZ: '自動歸零', moveAnyway: '強制移動', - homingStarted: 'Z 軸歸零中…', + homingStarted: '印表機自動歸零中…', }, // Permissions permission: { diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index f2087c87c..7c9fa239b 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -4768,7 +4768,7 @@ function PrinterCard({