Browse Source

feat: build-plate Z-jog control from printer card (#791)

  Adds a compact "Bed" badge in the printer-card controls row
  between print speed and Stop/Pause. Opens a popover with up/down
  arrows and a 1 / 10 / 50 mm step selector.

  When the Z axis has not been homed since the last print, the
  first jog per session opens a Bambu Studio-style modal with
  Home Z / Move anyway / Cancel. "Move anyway" bypasses soft
  endstops (M211 S0 ... M211 S1) for a single move and is
  remembered for the browser session.

  Backend:
  - POST /printers/{id}/bed-jog?distance=N[&force=bool]
    Emits G91 / G1 ZN F600 / G90 (with optional M211 wrap).
    Distance validated server-side (non-zero, |N| <= 200 mm).
  - POST /printers/{id}/home-axes?axes=z|xy|all
    Emits G28 variants.
  Both gated behind Permission.PRINTERS_CONTROL.

  Frontend:
  - New indigo-themed badge + popover in PrintersPage.
  - Not-homed confirmation modal with sessionStorage "warned" flag.
  - i18n keys under printers.bedJog.* in all 7 locales.

  Tests:
  - backend/tests/unit/test_bed_jog.py — 13 tests covering
    404 / 400 / 500 / success paths for both endpoints, plus
    gcode-payload assertions for force on/off.

  Docs:
  - README feature list, CHANGELOG (0.2.3b4 Unreleased),
    printer-control wiki page, website features.html.
maziggy 1 month ago
parent
commit
44bb179364

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b4] - Unreleased
 ## [0.2.3b4] - Unreleased
 
 
 ### New Features
 ### New Features
+- **Move Build Plate from Printer Card** ([#791](https://github.com/maziggy/bambuddy/issues/791)) — The printer card controls row now has a Z-jog badge between the speed control and the stop/pause buttons. Click the up/down arrows to move the build plate; click the middle label to switch the step size (1 / 10 / 50 mm). When the printer is not homed (typical right after a print finishes), the first jog opens a Bambu Studio-style warning modal with **Home Z**, **Move anyway** (bypasses soft endstops for this move), or **Cancel**. After the first "Move anyway" in a session, subsequent jogs skip the dialog. Disabled while a print is running. Backed by new `POST /printers/{id}/bed-jog` and `POST /printers/{id}/home-axes` endpoints, both gated behind `printers:control`. Thanks to @cadtoolbox for the request.
+
 - **Printer Card Status Badges & Quick Controls** — The Printers page printer card now exposes four new at-a-glance controls inspired by the Home Assistant Bambu Lab integration:
 - **Printer Card Status Badges & Quick Controls** — The Printers page printer card now exposes four new at-a-glance controls inspired by the Home Assistant Bambu Lab integration:
   - **SD Card badge** in the top status row (HardDrive icon, green when card present, red when missing).
   - **SD Card badge** in the top status row (HardDrive icon, green when card present, red when missing).
   - **Enclosure Door badge** in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family — `home_flag` bit 23 on X1/X1C/X1E and the top-level `stat` hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).
   - **Enclosure Door badge** in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family — `home_flag` bit 23 on X1/X1C/X1E and the top-level `stat` hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).

+ 1 - 1
README.md

@@ -102,7 +102,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Fan status monitoring (part cooling, auxiliary, chamber)
-- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*)
+- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **build-plate Z-jog** with Studio-style not-homed warning)
 - **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating)
 - **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating)
 - **Force Refresh** menu item — request a full status push from the printer without reconnecting
 - **Force Refresh** menu item — request a full status push from the printer without reconnecting
 - Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)
 - Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)

+ 75 - 0
backend/app/api/routes/printers.py

@@ -2428,6 +2428,81 @@ async def set_chamber_light(
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
 
 
 
 
+@router.post("/{printer_id}/bed-jog")
+async def bed_jog(
+    printer_id: int,
+    distance: float = Query(
+        ..., description="Relative Z distance in mm (positive = bed down / nozzle further away, negative = bed up)"
+    ),
+    force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Move the build plate along the Z axis 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.
+    """
+    if distance == 0 or abs(distance) > 200:
+        raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    lines = []
+    if force:
+        lines.append("M211 S0")
+    lines += ["G91", f"G1 Z{distance:.2f} F600", "G90"]
+    if force:
+        lines.append("M211 S1")
+
+    if not client.send_gcode("\n".join(lines)):
+        raise HTTPException(500, "Failed to send bed-jog command")
+
+    return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
+
+
+@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'"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Home one or more axes via G28."""
+    axes = axes.lower()
+    if axes == "z":
+        gcode = "G28 Z"
+    elif axes == "xy":
+        gcode = "G28 X Y"
+    elif axes == "all":
+        gcode = "G28"
+    else:
+        raise HTTPException(400, "axes must be 'z', 'xy', or 'all'")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    if not client.send_gcode(gcode):
+        raise HTTPException(500, "Failed to send home command")
+
+    return {"success": True, "message": f"Home {axes} command sent"}
+
+
 @router.post("/{printer_id}/hms/clear")
 @router.post("/{printer_id}/hms/clear")
 async def clear_hms_errors(
 async def clear_hms_errors(
     printer_id: int,
     printer_id: int,

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

@@ -0,0 +1,118 @@
+"""Unit tests for the bed-jog and home-axes endpoints (#791).
+
+Tests:
+  POST /api/v1/printers/{printer_id}/bed-jog?distance=<mm>&force=<bool>
+  POST /api/v1/printers/{printer_id}/home-axes?axes=<z|xy|all>
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestBedJogAPI:
+    @pytest.mark.asyncio
+    async def test_bed_jog_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/bed-jog?distance=10")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_bed_jog_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P1")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=0")
+        assert response.status_code == 400
+        assert "distance" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    async def test_bed_jog_too_large_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P1")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=500")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    async def test_bed_jog_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="Disconnected")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    async def test_bed_jog_send_failure(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P1")
+        mock_client = MagicMock()
+        mock_client.send_gcode.return_value = False
+        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 == 500
+
+    @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."""
+        printer = await printer_factory(name="P1")
+        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&force=false")
+            assert response.status_code == 200
+            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
+
+    @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")
+        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=-5&force=true")
+            assert response.status_code == 200
+            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
+
+
+class TestHomeAxesAPI:
+    @pytest.mark.asyncio
+    async def test_home_axes_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/home-axes?axes=z")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_home_axes_invalid(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P1")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=bogus")
+        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):
+        printer = await printer_factory(name="P1")
+        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}/home-axes?axes={axes}")
+            assert response.status_code == 200
+            mock_client.send_gcode.assert_called_once_with(expected)
+
+    @pytest.mark.asyncio
+    async def test_home_axes_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="D")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=z")
+            assert response.status_code == 400

+ 12 - 0
frontend/src/api/client.ts

@@ -2818,6 +2818,18 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
+  // Bed (Z-axis) jog
+  bedJog: (printerId: number, distance: number, force: boolean = false) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/bed-jog?distance=${distance}&force=${force}`,
+      { method: 'POST' }
+    ),
+  homeAxes: (printerId: number, axes: 'z' | 'xy' | 'all' = 'z') =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/home-axes?axes=${axes}`,
+      { method: 'POST' }
+    ),
+
   // Chamber Light Control
   // Chamber Light Control
   setChamberLight: (printerId: number, on: boolean) =>
   setChamberLight: (printerId: number, on: boolean) =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {

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

@@ -327,6 +327,19 @@ export default {
     rfid: {
     rfid: {
       reread: 'RFID neu lesen',
       reread: 'RFID neu lesen',
     },
     },
+    bedJog: {
+      title: 'Druckbett bewegen',
+      bed: 'Bett',
+      step: 'Schritt (mm)',
+      up: 'Platte hoch',
+      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',
+      moveAnyway: 'Trotzdem bewegen',
+      homingStarted: 'Z-Achse wird referenziert…',
+    },
     // Permissions
     // Permissions
     permission: {
     permission: {
       noAdd: 'Sie haben keine Berechtigung, Drucker hinzuzufügen',
       noAdd: 'Sie haben keine Berechtigung, Drucker hinzuzufügen',

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

@@ -327,6 +327,19 @@ export default {
     rfid: {
     rfid: {
       reread: 'Re-read RFID',
       reread: 'Re-read RFID',
     },
     },
+    bedJog: {
+      title: 'Move build plate',
+      bed: 'Bed',
+      step: 'Step (mm)',
+      up: 'Move plate up',
+      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',
+      moveAnyway: 'Move anyway',
+      homingStarted: 'Homing Z axis…',
+    },
     // Permissions
     // Permissions
     permission: {
     permission: {
       noAdd: 'You do not have permission to add printers',
       noAdd: 'You do not have permission to add printers',

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

@@ -327,6 +327,19 @@ export default {
     rfid: {
     rfid: {
       reread: 'Relire RFID',
       reread: 'Relire RFID',
     },
     },
+    bedJog: {
+      title: 'Déplacer le plateau',
+      bed: 'Plateau',
+      step: 'Pas (mm)',
+      up: 'Monter le plateau',
+      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',
+      moveAnyway: 'Déplacer quand même',
+      homingStarted: 'Référencement de l\'axe Z…',
+    },
     // Permissions
     // Permissions
     permission: {
     permission: {
       noAdd: 'Pas d\'autorisation pour ajouter',
       noAdd: 'Pas d\'autorisation pour ajouter',

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

@@ -327,6 +327,19 @@ export default {
     rfid: {
     rfid: {
       reread: 'Rileggi RFID',
       reread: 'Rileggi RFID',
     },
     },
+    bedJog: {
+      title: 'Muovi il piano di stampa',
+      bed: 'Piano',
+      step: 'Passo (mm)',
+      up: 'Sposta piano su',
+      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',
+      moveAnyway: 'Muovi comunque',
+      homingStarted: 'Azzeramento asse Z…',
+    },
     // Permissions
     // Permissions
     permission: {
     permission: {
       noAdd: 'Non hai il permesso di aggiungere stampanti',
       noAdd: 'Non hai il permesso di aggiungere stampanti',

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

@@ -326,6 +326,19 @@ export default {
     rfid: {
     rfid: {
       reread: 'RFID再読み取り',
       reread: 'RFID再読み取り',
     },
     },
+    bedJog: {
+      title: 'ビルドプレートを移動',
+      bed: 'ベッド',
+      step: 'ステップ (mm)',
+      up: 'プレートを上へ',
+      down: 'プレートを下へ',
+      disabledWhilePrinting: '印刷中は無効',
+      notHomedTitle: 'プリンターがホーミングされていません',
+      notHomedMessage: '前回の印刷以降、Z軸がホーミングされていません。安全な位置決めのためにまずZをホーミングするか、このまま移動してください — ソフトエンドストップはバイパスされます。',
+      homeZ: 'Zをホーミング',
+      moveAnyway: 'このまま移動',
+      homingStarted: 'Z軸をホーミング中…',
+    },
     // Permissions
     // Permissions
     permission: {
     permission: {
       noAdd: 'プリンターを追加する権限がありません',
       noAdd: 'プリンターを追加する権限がありません',

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

@@ -327,6 +327,19 @@ export default {
     rfid: {
     rfid: {
       reread: 'Releitura de RFID',
       reread: 'Releitura de RFID',
     },
     },
+    bedJog: {
+      title: 'Mover a mesa de impressão',
+      bed: 'Mesa',
+      step: 'Passo (mm)',
+      up: 'Mover mesa para cima',
+      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',
+      moveAnyway: 'Mover assim mesmo',
+      homingStarted: 'Referenciando eixo Z…',
+    },
     // Permissions
     // Permissions
     permission: {
     permission: {
       noAdd: 'Você não tem permissão para adicionar impressoras',
       noAdd: 'Você não tem permissão para adicionar impressoras',

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

@@ -327,6 +327,19 @@ export default {
     rfid: {
     rfid: {
       reread: '重新读取 RFID',
       reread: '重新读取 RFID',
     },
     },
+    bedJog: {
+      title: '移动热床',
+      bed: '热床',
+      step: '步长 (mm)',
+      up: '热床上移',
+      down: '热床下移',
+      disabledWhilePrinting: '打印中已禁用',
+      notHomedTitle: '打印机未归零',
+      notHomedMessage: '自上次打印以来 Z 轴尚未归零。请先归零 Z 以确保安全定位,或者直接移动 — 软限位将被绕过。',
+      homeZ: '归零 Z',
+      moveAnyway: '强制移动',
+      homingStarted: 'Z 轴归零中…',
+    },
     // Permissions
     // Permissions
     permission: {
     permission: {
       noAdd: '您没有添加打印机的权限',
       noAdd: '您没有添加打印机的权限',

+ 153 - 1
frontend/src/pages/PrintersPage.tsx

@@ -53,6 +53,7 @@ import {
   Gauge,
   Gauge,
   DoorOpen,
   DoorOpen,
   DoorClosed,
   DoorClosed,
+  MoveVertical,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
@@ -1332,6 +1333,9 @@ function PrinterCard({
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
   const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
   const [showAirductMenu, setShowAirductMenu] = useState<number | null>(null);
   const [showAirductMenu, setShowAirductMenu] = useState<number | null>(null);
+  const [showBedJogMenu, setShowBedJogMenu] = useState<number | null>(null);
+  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);
@@ -1793,6 +1797,20 @@ function PrinterCard({
     },
     },
   });
   });
 
 
+  const bedJogMutation = useMutation({
+    mutationFn: ({ distance, force }: { distance: number; force?: boolean }) =>
+      api.bedJog(printer.id, distance, force ?? false),
+    onError: (error: Error) =>
+      showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
+  });
+
+  const homeAxesMutation = useMutation({
+    mutationFn: (axes: 'z' | 'xy' | 'all') => api.homeAxes(printer.id, axes),
+    onSuccess: () => showToast(t('printers.bedJog.homingStarted')),
+    onError: (error: Error) =>
+      showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
+  });
+
   // Plate detection setting mutation
   // Plate detection setting mutation
   const plateDetectionMutation = useMutation({
   const plateDetectionMutation = useMutation({
     mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }),
     mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }),
@@ -2431,7 +2449,7 @@ function PrinterCard({
               {queueCount > 0 && (
               {queueCount > 0 && (
                 <button
                 <button
                   onClick={() => navigate('/queue')}
                   onClick={() => navigate('/queue')}
-                  className="flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-purple-500/20 text-purple-400 hover:opacity-80 transition-opacity"
+                  className="flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-indigo-500/20 text-indigo-400 hover:opacity-80 transition-opacity"
                   title={t('printers.queue.inQueue', { count: queueCount })}
                   title={t('printers.queue.inQueue', { count: queueCount })}
                 >
                 >
                   <Layers className="w-3 h-3" />
                   <Layers className="w-3 h-3" />
@@ -2952,6 +2970,93 @@ function PrinterCard({
                         );
                         );
                       })()}
                       })()}
 
 
+                      {/* Separator */}
+                      <div className="w-px h-5 bg-bambu-gray/30" />
+
+                      {/* Bed Jog (Z-axis) — compact badge, popover holds the actual controls */}
+                      {(() => {
+                        const canControl = hasPermission('printers:control');
+                        const disabled = isPrinting || !canControl;
+                        const bambuIsPlateBelow = true; // positive Z moves plate away from nozzle
+                        const requestJog = (direction: 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; }
+                          })();
+                          setShowBedJogMenu(null);
+                          if (warned) {
+                            bedJogMutation.mutate({ distance: signed, force: true });
+                          } else {
+                            setShowNotHomedModal({ distance: signed });
+                          }
+                        };
+                        return (
+                          <div className="relative">
+                            <button
+                              onClick={() => setShowBedJogMenu(showBedJogMenu === printer.id ? null : printer.id)}
+                              disabled={disabled}
+                              className={`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${
+                                disabled
+                                  ? 'bg-bambu-dark cursor-not-allowed'
+                                  : 'bg-indigo-500/10 hover:bg-indigo-500/20'
+                              }`}
+                              title={!canControl ? t('printers.permission.noControl') : isPrinting ? t('printers.bedJog.disabledWhilePrinting') : t('printers.bedJog.title')}
+                            >
+                              <MoveVertical className={`w-3.5 h-3.5 ${disabled ? 'text-bambu-gray/50' : 'text-indigo-400'}`} />
+                              <span className={`text-[10px] ${disabled ? 'text-bambu-gray/50' : 'text-indigo-400'}`}>
+                                {t('printers.bedJog.bed')}
+                              </span>
+                              <span className={`text-[10px] tabular-nums opacity-70 ${disabled ? 'text-bambu-gray/50' : 'text-indigo-400'}`}>
+                                {bedJogStep}mm
+                              </span>
+                            </button>
+                            {showBedJogMenu === printer.id && (
+                              <>
+                                <div className="fixed inset-0 z-40" onClick={() => setShowBedJogMenu(null)} />
+                                <div className="absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg p-2 min-w-[140px]">
+                                  <div className="flex items-center justify-between gap-1 mb-2">
+                                    <button
+                                      onClick={() => requestJog(-1)}
+                                      className="flex-1 flex items-center justify-center py-1.5 rounded bg-indigo-500/15 hover:bg-indigo-500/30 text-indigo-300"
+                                      aria-label={t('printers.bedJog.up')}
+                                    >
+                                      <ArrowUp className="w-4 h-4" />
+                                    </button>
+                                    <button
+                                      onClick={() => requestJog(1)}
+                                      className="flex-1 flex items-center justify-center py-1.5 rounded bg-indigo-500/15 hover:bg-indigo-500/30 text-indigo-300"
+                                      aria-label={t('printers.bedJog.down')}
+                                    >
+                                      <ArrowDown className="w-4 h-4" />
+                                    </button>
+                                  </div>
+                                  <div className="text-[9px] uppercase tracking-wider text-bambu-gray/70 px-1 mb-1">
+                                    {t('printers.bedJog.step')}
+                                  </div>
+                                  <div className="flex gap-1">
+                                    {[1, 10, 50].map((step) => (
+                                      <button
+                                        key={step}
+                                        onClick={() => setBedJogStep(step)}
+                                        className={`flex-1 px-1 py-1 rounded text-[10px] transition-colors ${
+                                          bedJogStep === step
+                                            ? 'bg-bambu-green/20 text-bambu-green'
+                                            : 'bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary'
+                                        }`}
+                                      >
+                                        {step}
+                                      </button>
+                                    ))}
+                                  </div>
+                                </div>
+                              </>
+                            )}
+                          </div>
+                        );
+                      })()}
+
                     </div>
                     </div>
 
 
                     {/* Right: Print Control Buttons */}
                     {/* Right: Print Control Buttons */}
@@ -4538,6 +4643,53 @@ 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-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('z');
+                  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-500/20 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 - 0
static/assets/index-3s5orqQ4.css


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


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


+ 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-DLtHRwHs.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Df0jWYPb.css">
+    <script type="module" crossorigin src="/assets/index-Dvdlpt_K.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.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