mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 13:11:35 +02:00
fix(printer): bed-jog "Home Z" could crash bed into toolhead on H2C/H2D/H2S/X1 (#1052)
Critical safety fix. The bed-jog dialog's "Home Z" button sent a bare
`G28 Z` over gcode_line. On Bambu printers where the Z endstop is at
the top (bed moves UP into it — H2C, H2D, H2S, X1 family), `G28 Z`
skips the toolhead-park step that a full `G28` runs first, so the bed
rises at full speed with nothing getting out of the way. The reporter
only escaped damage because the toolhead happened to be parked on the
purge chute.
The /printers/{id}/home-axes endpoint and BambuClient.home_axes() now
always send bare `G28` regardless of the axes argument, triggering the
firmware's safe multi-step routine (park toolhead → home XY → home Z).
The axes argument is kept for API compat but ignored; invalid values
still return 400.
Frontend retitles the button "Auto Home" and updates the dialog copy
in all 7 locales so users aren't surprised when X/Y motion happens
before Z. Parameterized regression test asserts z/xy/all all produce
bare G28.
(cherry picked from commit 7026a6de77)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -4768,7 +4768,7 @@ function PrinterCard({
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
homeAxesMutation.mutate('z');
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user