mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
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.
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.3b4] - Unreleased
|
||||
|
||||
### 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:
|
||||
- **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).
|
||||
|
||||
@@ -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
|
||||
- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
|
||||
- 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)
|
||||
- **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)
|
||||
|
||||
@@ -2428,6 +2428,81 @@ async def set_chamber_light(
|
||||
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")
|
||||
async def clear_hms_errors(
|
||||
printer_id: int,
|
||||
|
||||
@@ -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
|
||||
@@ -2818,6 +2818,18 @@ export const api = {
|
||||
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
|
||||
setChamberLight: (printerId: number, on: boolean) =>
|
||||
request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
|
||||
|
||||
@@ -327,6 +327,19 @@ export default {
|
||||
rfid: {
|
||||
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
|
||||
permission: {
|
||||
noAdd: 'Sie haben keine Berechtigung, Drucker hinzuzufügen',
|
||||
|
||||
@@ -327,6 +327,19 @@ export default {
|
||||
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
|
||||
permission: {
|
||||
noAdd: 'You do not have permission to add printers',
|
||||
|
||||
@@ -327,6 +327,19 @@ export default {
|
||||
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
|
||||
permission: {
|
||||
noAdd: 'Pas d\'autorisation pour ajouter',
|
||||
|
||||
@@ -327,6 +327,19 @@ export default {
|
||||
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
|
||||
permission: {
|
||||
noAdd: 'Non hai il permesso di aggiungere stampanti',
|
||||
|
||||
@@ -326,6 +326,19 @@ export default {
|
||||
rfid: {
|
||||
reread: 'RFID再読み取り',
|
||||
},
|
||||
bedJog: {
|
||||
title: 'ビルドプレートを移動',
|
||||
bed: 'ベッド',
|
||||
step: 'ステップ (mm)',
|
||||
up: 'プレートを上へ',
|
||||
down: 'プレートを下へ',
|
||||
disabledWhilePrinting: '印刷中は無効',
|
||||
notHomedTitle: 'プリンターがホーミングされていません',
|
||||
notHomedMessage: '前回の印刷以降、Z軸がホーミングされていません。安全な位置決めのためにまずZをホーミングするか、このまま移動してください — ソフトエンドストップはバイパスされます。',
|
||||
homeZ: 'Zをホーミング',
|
||||
moveAnyway: 'このまま移動',
|
||||
homingStarted: 'Z軸をホーミング中…',
|
||||
},
|
||||
// Permissions
|
||||
permission: {
|
||||
noAdd: 'プリンターを追加する権限がありません',
|
||||
|
||||
@@ -327,6 +327,19 @@ export default {
|
||||
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
|
||||
permission: {
|
||||
noAdd: 'Você não tem permissão para adicionar impressoras',
|
||||
|
||||
@@ -327,6 +327,19 @@ export default {
|
||||
rfid: {
|
||||
reread: '重新读取 RFID',
|
||||
},
|
||||
bedJog: {
|
||||
title: '移动热床',
|
||||
bed: '热床',
|
||||
step: '步长 (mm)',
|
||||
up: '热床上移',
|
||||
down: '热床下移',
|
||||
disabledWhilePrinting: '打印中已禁用',
|
||||
notHomedTitle: '打印机未归零',
|
||||
notHomedMessage: '自上次打印以来 Z 轴尚未归零。请先归零 Z 以确保安全定位,或者直接移动 — 软限位将被绕过。',
|
||||
homeZ: '归零 Z',
|
||||
moveAnyway: '强制移动',
|
||||
homingStarted: 'Z 轴归零中…',
|
||||
},
|
||||
// Permissions
|
||||
permission: {
|
||||
noAdd: '您没有添加打印机的权限',
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
Gauge,
|
||||
DoorOpen,
|
||||
DoorClosed,
|
||||
MoveVertical,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -1332,6 +1333,9 @@ function PrinterCard({
|
||||
const [showPauseConfirm, setShowPauseConfirm] = useState(false);
|
||||
const [showSpeedMenu, setShowSpeedMenu] = 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 [showSkipObjectsModal, setShowSkipObjectsModal] = 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
|
||||
const plateDetectionMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }),
|
||||
@@ -2431,7 +2449,7 @@ function PrinterCard({
|
||||
{queueCount > 0 && (
|
||||
<button
|
||||
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 })}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<SkipObjectsModal
|
||||
printerId={printer.id}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user