Ver código fonte

feat: Update printer card UI for structure and readability (#1661)

maziggy 2 meses atrás
pai
commit
6fa74be429

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

@@ -52,6 +52,7 @@ from backend.app.services.printer_manager import (
     get_derived_status_name,
     get_derived_status_name,
     printer_manager,
     printer_manager,
     resolve_plate_id,
     resolve_plate_id,
+    supports_chamber_heater,
     supports_chamber_temp,
     supports_chamber_temp,
     supports_drying,
     supports_drying,
 )
 )
@@ -725,6 +726,7 @@ async def get_printer_status(
         developer_mode=state.developer_mode if state else None,
         developer_mode=state.developer_mode if state else None,
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
         supports_drying=supports_drying(printer.model, state.firmware_version),
+        supports_chamber_heater=supports_chamber_heater(printer.model),
         current_archive_id=current_archive_id,
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,
         current_plate_id=current_plate_id,
         fila_switch=(
         fila_switch=(
@@ -2806,6 +2808,144 @@ async def set_print_speed(
     return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
     return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
 
 
 
 
+@router.post("/{printer_id}/temperature/nozzle")
+async def set_nozzle_temperature(
+    printer_id: int,
+    target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
+    nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set a nozzle target temperature."""
+    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")
+
+    success = client.set_nozzle_temperature(target, nozzle)
+    if not success:
+        raise HTTPException(500, "Failed to set nozzle temperature")
+
+    return {"success": True, "message": f"Nozzle temperature set to {target}°C"}
+
+
+@router.post("/{printer_id}/temperature/bed")
+async def set_bed_temperature(
+    printer_id: int,
+    target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the bed target temperature."""
+    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")
+
+    success = client.set_bed_temperature(target)
+    if not success:
+        raise HTTPException(500, "Failed to set bed temperature")
+
+    return {"success": True, "message": f"Bed temperature set to {target}°C"}
+
+
+@router.post("/{printer_id}/temperature/chamber")
+async def set_chamber_temperature(
+    printer_id: int,
+    target: int = Query(..., ge=0, le=60, description="Target chamber temperature in Celsius; 0 turns heating off"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the chamber target temperature.
+
+    Gated on `supports_chamber_heater(model)`: only H2C, H2D, H2D Pro, H2S,
+    and X2D have an active chamber heater. Sensor-only models (X1C, X1E,
+    P2S) report chamber temp but silently swallow M141, so we 400 here
+    rather than send a no-op.
+    """
+    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")
+
+    if not supports_chamber_heater(printer.model):
+        raise HTTPException(400, f"Model {printer.model or 'unknown'} does not have an active chamber heater")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_chamber_temperature(target)
+    if not success:
+        raise HTTPException(500, "Failed to set chamber temperature")
+
+    return {"success": True, "message": f"Chamber temperature set to {target}°C"}
+
+
+@router.post("/{printer_id}/fan-speed")
+async def set_fan_speed(
+    printer_id: int,
+    fan: str = Query(..., description="Fan to control: part, aux, or chamber"),
+    speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set a fan speed by percentage."""
+    fan_ids = {"part": 1, "aux": 2, "chamber": 3}
+    fan_id = fan_ids.get(fan)
+    if fan_id is None:
+        raise HTTPException(400, "fan must be 'part', 'aux', or 'chamber'")
+
+    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")
+
+    pwm_speed = round(speed * 255 / 100)
+    success = client.set_fan_speed(fan_id, pwm_speed)
+    if not success:
+        raise HTTPException(500, "Failed to set fan speed")
+
+    fan_names = {"part": "Part cooling fan", "aux": "Auxiliary fan", "chamber": "Chamber fan"}
+    return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
+
+
+@router.post("/{printer_id}/select-extruder")
+async def select_extruder(
+    printer_id: int,
+    extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Select the active extruder/nozzle on dual-nozzle printers."""
+    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")
+
+    success = client.select_extruder(extruder)
+    if not success:
+        raise HTTPException(500, "Failed to select nozzle")
+
+    return {"success": True, "message": f"{'Left' if extruder == 1 else 'Right'} nozzle selected"}
+
+
 @router.post("/{printer_id}/airduct-mode")
 @router.post("/{printer_id}/airduct-mode")
 async def set_airduct_mode(
 async def set_airduct_mode(
     printer_id: int,
     printer_id: int,
@@ -2918,6 +3058,72 @@ async def bed_jog(
     return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
     return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
 
 
 
 
+@router.post("/{printer_id}/xy-jog")
+async def xy_jog(
+    printer_id: int,
+    x: float = Query(0, description="Signed relative X movement in mm"),
+    y: float = Query(0, description="Signed relative Y movement in mm"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Move the toolhead by a relative X/Y distance."""
+    if (x == 0 and y == 0) or abs(x) > 200 or abs(y) > 200:
+        raise HTTPException(400, "X/Y movement must be non-zero and ≤ 200 mm per axis")
+
+    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")
+
+    axes = []
+    if x:
+        axes.append(f"X{x:.2f}")
+    if y:
+        axes.append(f"Y{y:.2f}")
+
+    if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
+        raise HTTPException(500, "Failed to send XY jog command")
+
+    return {"success": True, "message": f"XY jog X{x:+.1f} Y{y:+.1f} mm sent"}
+
+
+@router.post("/{printer_id}/extruder-jog")
+async def extruder_jog(
+    printer_id: int,
+    distance: float = Query(
+        ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
+    ),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Extrude or retract filament by a relative distance.
+
+    No client-side cold-extrude guard: Bambu firmware refuses extrusion
+    below its min-extrude temperature, so a cold call is rejected at the
+    printer, not silently damaging the extruder gear.
+    """
+    if distance == 0 or abs(distance) > 100:
+        raise HTTPException(400, "Extruder movement must be non-zero and ≤ 100 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")
+
+    if not client.send_gcode("\n".join(["M83", f"G1 E{distance:.2f} F300", "M82"])):
+        raise HTTPException(500, "Failed to send extruder jog command")
+
+    return {"success": True, "message": f"Extruder jog {distance:+.1f} mm sent"}
+
+
 @router.post("/{printer_id}/home-axes")
 @router.post("/{printer_id}/home-axes")
 async def home_axes(
 async def home_axes(
     printer_id: int,
     printer_id: int,

+ 7 - 4
backend/app/api/routes/settings.py

@@ -346,6 +346,12 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = (
     "ams_temp_good",
     "ams_temp_good",
     "ams_temp_fair",
     "ams_temp_fair",
     "bed_cooled_threshold",
     "bed_cooled_threshold",
+    # Temperature / fan-speed presets for the printer-card popovers. Numbers
+    # only; no PII / credentials.
+    "nozzle_temp_presets",
+    "bed_temp_presets",
+    "chamber_temp_presets",
+    "fan_speed_presets",
 )
 )
 
 
 
 
@@ -446,10 +452,7 @@ async def update_spoolman_settings(
     if "spoolman_report_partial_usage" in settings:
     if "spoolman_report_partial_usage" in settings:
         await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
         await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
 
 
-    spoolman_changed = (
-        "spoolman_enabled" in settings
-        or "spoolman_url" in settings
-    )
+    spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
 
 
     await db.commit()
     await db.commit()
     db.expire_all()
     db.expire_all()

+ 2 - 0
backend/app/schemas/printer.py

@@ -329,6 +329,8 @@ class PrinterStatus(BaseModel):
     awaiting_plate_clear: bool = False
     awaiting_plate_clear: bool = False
     # AMS drying support
     # AMS drying support
     supports_drying: bool = False
     supports_drying: bool = False
+    # Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
+    supports_chamber_heater: bool = False
     # Linked archive for the active print (resolved via subtask_id). Frontend uses
     # Linked archive for the active print (resolved via subtask_id). Frontend uses
     # this to fetch plate metadata and show the plate name when the source 3MF is
     # this to fetch plate metadata and show the plate name when the source 3MF is
     # multi-plate (#881 follow-up).
     # multi-plate (#881 follow-up).

+ 62 - 0
backend/app/schemas/settings.py

@@ -293,6 +293,27 @@ class AppSettings(BaseModel):
         description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
         description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
     )
     )
 
 
+    # User-configurable presets for the printer-card temperature / fan-speed
+    # popovers. Each is a JSON array of exactly 3 ints (the "Off" button is
+    # rendered separately and is not configurable). Empty string = use built-in
+    # defaults. Validators on AppSettingsUpdate enforce the shape on writes.
+    nozzle_temp_presets: str = Field(
+        default="",
+        description="JSON array of 3 nozzle-temperature preset values in C (0-320). Empty = use defaults [120, 220, 260]",
+    )
+    bed_temp_presets: str = Field(
+        default="",
+        description="JSON array of 3 bed-temperature preset values in C (0-140). Empty = use defaults [55, 75, 90]",
+    )
+    chamber_temp_presets: str = Field(
+        default="",
+        description="JSON array of 3 chamber-temperature preset values in C (0-60). Empty = use defaults [35, 45, 60]",
+    )
+    fan_speed_presets: str = Field(
+        default="",
+        description="JSON array of 3 fan-speed preset values in % (0-100). Empty = use defaults [50, 75, 100]",
+    )
+
     # LDAP authentication (#794)
     # LDAP authentication (#794)
     ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
     ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
     ldap_server_url: str = Field(default="", description="LDAP server URL (e.g., ldap://ldap.example.com:389)")
     ldap_server_url: str = Field(default="", description="LDAP server URL (e.g., ldap://ldap.example.com:389)")
@@ -440,6 +461,10 @@ class AppSettingsUpdate(BaseModel):
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
     queue_shortest_first: bool | None = None
+    nozzle_temp_presets: str | None = None
+    bed_temp_presets: str | None = None
+    chamber_temp_presets: str | None = None
+    fan_speed_presets: str | None = None
     gcode_snippets: str | None = None
     gcode_snippets: str | None = None
     local_backup_enabled: bool | None = None
     local_backup_enabled: bool | None = None
     local_backup_schedule: str | None = None
     local_backup_schedule: str | None = None
@@ -504,6 +529,43 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)")
             raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)")
         return v
         return v
 
 
+    @staticmethod
+    def _validate_preset_triple(v: str | None, field_name: str, lo: int, hi: int) -> str | None:
+        """Validate a JSON array of exactly 3 ints in [lo, hi]. Empty = defaults."""
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError(f"{field_name} must be valid JSON or empty")
+        if not isinstance(parsed, list) or len(parsed) != 3:
+            raise ValueError(f"{field_name} must be a JSON array of exactly 3 integers")
+        if not all(isinstance(item, int) and not isinstance(item, bool) for item in parsed):
+            raise ValueError(f"{field_name} entries must all be integers")
+        if not all(lo <= item <= hi for item in parsed):
+            raise ValueError(f"{field_name} entries must each be in [{lo}, {hi}]")
+        return v
+
+    @field_validator("nozzle_temp_presets")
+    @classmethod
+    def validate_nozzle_temp_presets(cls, v: str | None) -> str | None:
+        return cls._validate_preset_triple(v, "nozzle_temp_presets", 0, 320)
+
+    @field_validator("bed_temp_presets")
+    @classmethod
+    def validate_bed_temp_presets(cls, v: str | None) -> str | None:
+        return cls._validate_preset_triple(v, "bed_temp_presets", 0, 140)
+
+    @field_validator("chamber_temp_presets")
+    @classmethod
+    def validate_chamber_temp_presets(cls, v: str | None) -> str | None:
+        return cls._validate_preset_triple(v, "chamber_temp_presets", 0, 60)
+
+    @field_validator("fan_speed_presets")
+    @classmethod
+    def validate_fan_speed_presets(cls, v: str | None) -> str | None:
+        return cls._validate_preset_triple(v, "fan_speed_presets", 0, 100)
+
     @field_validator("obico_sensitivity")
     @field_validator("obico_sensitivity")
     @classmethod
     @classmethod
     def validate_obico_sensitivity(cls, v: str | None) -> str | None:
     def validate_obico_sensitivity(cls, v: str | None) -> str | None:

+ 37 - 0
backend/app/services/printer_manager.py

@@ -85,6 +85,43 @@ def supports_chamber_temp(model: str | None) -> bool:
     return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
     return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
 
 
 
 
+# Models with an ACTIVE chamber heater (M141 has an effect).
+# Many printers in CHAMBER_TEMP_SUPPORTED_MODELS only have a passive sensor —
+# X1C, X1E, P2S report chamber temperature but cannot actively heat it. Only
+# the models below ship a PTC heater that responds to M141.
+CHAMBER_HEATER_MODELS = frozenset(
+    [
+        # Display names
+        "H2C",
+        "H2D",
+        "H2DPRO",
+        "H2S",
+        "X2D",
+        # Internal codes (from MQTT/SSDP)
+        "O1C",  # H2C
+        "O1C2",  # H2C dual-nozzle variant
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro alternate code
+        "O1S",  # H2S
+        "N6",  # X2D
+    ]
+)
+
+
+def supports_chamber_heater(model: str | None) -> bool:
+    """Check if a printer model has an active chamber heater (responds to M141).
+
+    The chamber temperature SENSOR is more widely deployed than the chamber
+    HEATER — X1C/X1E/P2S report chamber temp but ignore M141. Only H2C, H2D,
+    H2D Pro, H2S, X2D actually heat. Sensor-only models silently swallow the
+    command at the firmware level, so we 400 at the route to surface that.
+    """
+    if not model:
+        return False
+    return model.strip().upper() in CHAMBER_HEATER_MODELS
+
+
 def has_stg_cur_idle_bug(model: str | None) -> bool:
 def has_stg_cur_idle_bug(model: str | None) -> bool:
     """Check if a printer model may incorrectly report stg_cur=0 when idle.
     """Check if a printer model may incorrectly report stg_cur=0 when idle.
 
 

+ 457 - 0
backend/tests/integration/test_printers_api.py

@@ -3183,3 +3183,460 @@ class TestPrinterAccessCodeVisibility:
         assert "access_code" not in match or match["access_code"] is None
         assert "access_code" not in match or match["access_code"] is None
         body = detail_resp.json()
         body = detail_resp.json()
         assert "access_code" not in body or body["access_code"] is None
         assert "access_code" not in body or body["access_code"] is None
+
+
+class TestSetNozzleTemperatureAPI:
+    """Integration tests for POST /printers/{id}/temperature/nozzle (#1661)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/temperature/nozzle?target=220")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        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}/temperature/nozzle?target=220")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_nozzle_index(self, async_client: AsyncClient, printer_factory):
+        """Omitting nozzle defaults to 0 (right/default)."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_nozzle_temperature.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}/temperature/nozzle?target=220")
+        assert response.status_code == 200
+        mock_client.set_nozzle_temperature.assert_called_once_with(220, 0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dual_nozzle_left(self, async_client: AsyncClient, printer_factory):
+        """nozzle=1 reaches the client method as the second positional arg."""
+        printer = await printer_factory(name="P", model="H2D")
+        mock_client = MagicMock()
+        mock_client.set_nozzle_temperature.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}/temperature/nozzle?target=260&nozzle=1")
+        assert response.status_code == 200
+        mock_client.set_nozzle_temperature.assert_called_once_with(260, 1)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
+        """target=0 turns the heater off; must NOT be rejected by Query bounds."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_nozzle_temperature.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}/temperature/nozzle?target=0")
+        assert response.status_code == 200
+        mock_client.set_nozzle_temperature.assert_called_once_with(0, 0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=400")
+        assert response.status_code == 422  # FastAPI bounds violation
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_nozzle_index_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220&nozzle=2")
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_nozzle_temperature.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}/temperature/nozzle?target=220")
+        assert response.status_code == 500
+
+
+class TestSetBedTemperatureAPI:
+    """Integration tests for POST /printers/{id}/temperature/bed (#1661)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/temperature/bed?target=60")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        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}/temperature/bed?target=60")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_success(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_bed_temperature.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}/temperature/bed?target=60")
+        assert response.status_code == 200
+        mock_client.set_bed_temperature.assert_called_once_with(60)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
+        """target=0 turns the bed heater off."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_bed_temperature.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}/temperature/bed?target=0")
+        assert response.status_code == 200
+        mock_client.set_bed_temperature.assert_called_once_with(0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=200")
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_bed_temperature.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}/temperature/bed?target=60")
+        assert response.status_code == 500
+
+
+class TestSetChamberTemperatureAPI:
+    """Integration tests for POST /printers/{id}/temperature/chamber.
+
+    Gated on supports_chamber_heater(model). Sensor-only models that report
+    chamber temp but have no heater (X1C, X1E, P2S) get a 400 at the route
+    level rather than a silent no-op at the firmware level.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/temperature/chamber?target=45")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("sensor_only_model", ["X1C", "X1E", "P2S"])
+    async def test_sensor_only_model_rejected(self, async_client: AsyncClient, printer_factory, sensor_only_model):
+        """Models with sensor but no heater must 400 before any client call."""
+        printer = await printer_factory(name="P", model=sensor_only_model)
+        mock_client = MagicMock()
+        mock_client.set_chamber_temperature.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}/temperature/chamber?target=45")
+        assert response.status_code == 400
+        # Client must NOT be called for sensor-only models.
+        mock_client.set_chamber_temperature.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        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}/temperature/chamber?target=45")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("heater_model", ["H2C", "H2D", "H2DPRO", "H2S", "X2D"])
+    async def test_success_per_heater_model(self, async_client: AsyncClient, printer_factory, heater_model):
+        """All five heater-equipped models accept the command."""
+        printer = await printer_factory(name="P", model=heater_model)
+        mock_client = MagicMock()
+        mock_client.set_chamber_temperature.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}/temperature/chamber?target=45")
+        assert response.status_code == 200
+        mock_client.set_chamber_temperature.assert_called_once_with(45)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
+        """target=0 turns the chamber heater off."""
+        printer = await printer_factory(name="P", model="H2D")
+        mock_client = MagicMock()
+        mock_client.set_chamber_temperature.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}/temperature/chamber?target=0")
+        assert response.status_code == 200
+        mock_client.set_chamber_temperature.assert_called_once_with(0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=100")
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        mock_client = MagicMock()
+        mock_client.set_chamber_temperature.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}/temperature/chamber?target=45")
+        assert response.status_code == 500
+
+
+class TestSetFanSpeedAPI:
+    """Integration tests for POST /printers/{id}/fan-speed (#1661).
+
+    The fan-id mapping (part->1, aux->2, chamber->3) is the critical
+    correctness gate — wrong mapping would target the wrong physical fan.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_fan_name_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=foo&speed=50")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        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}/fan-speed?fan=part&speed=50")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "fan_name,expected_fan_id",
+        [("part", 1), ("aux", 2), ("chamber", 3)],
+    )
+    async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id):
+        """Verify each fan name maps to the correct hardware fan-id."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.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}/fan-speed?fan={fan_name}&speed=100")
+        assert response.status_code == 200
+        called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
+        assert called_fan_id == expected_fan_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "speed_pct,expected_pwm",
+        [(0, 0), (50, 128), (100, 255)],
+    )
+    async def test_pwm_conversion(self, async_client: AsyncClient, printer_factory, speed_pct, expected_pwm):
+        """0-100% must convert to 0-255 PWM (round-to-nearest)."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.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}/fan-speed?fan=part&speed={speed_pct}")
+        assert response.status_code == 200
+        _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
+        assert called_pwm == expected_pwm
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed=150")
+        assert response.status_code == 422
+
+
+class TestSelectExtruderAPI:
+    """Integration tests for POST /printers/{id}/select-extruder (#1661)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/select-extruder?extruder=0")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        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}/select-extruder?extruder=0")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("extruder", [0, 1])
+    async def test_select_each_extruder(self, async_client: AsyncClient, printer_factory, extruder):
+        printer = await printer_factory(name="P", model="H2D")
+        mock_client = MagicMock()
+        mock_client.select_extruder.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}/select-extruder?extruder={extruder}")
+        assert response.status_code == 200
+        mock_client.select_extruder.assert_called_once_with(extruder)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extruder_index_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder=2")
+        assert response.status_code == 422
+
+
+class TestXYJogAPI:
+    """Integration tests for POST /printers/{id}/xy-jog (#1661)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zero_movement_rejected(self, async_client: AsyncClient, printer_factory):
+        """x=0 AND y=0 (or omitted) must be rejected — no-op jog is a UI bug."""
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("x,y", [(201, 0), (0, 201), (-300, 0), (0, -250)])
+    async def test_oversize_movement_rejected(self, async_client: AsyncClient, printer_factory, x, y):
+        """Per-axis bound is 200mm; over-bound must be rejected."""
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x={x}&y={y}")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        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}/xy-jog?x=10&y=0")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_success_x_only_emits_relative_gcode(self, async_client: AsyncClient, printer_factory):
+        """X-only jog should emit G91/G90 wrapping and only include the X axis."""
+        printer = await printer_factory(name="P", model="X1C")
+        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}/xy-jog?x=10&y=0")
+        assert response.status_code == 200
+        sent = mock_client.send_gcode.call_args.args[0]
+        assert sent.startswith("G91\n")
+        assert sent.endswith("\nG90")
+        assert "X10.00" in sent
+        assert "Y" not in sent  # y=0 must NOT be included
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_success_both_axes(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        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}/xy-jog?x=-5&y=7")
+        assert response.status_code == 200
+        sent = mock_client.send_gcode.call_args.args[0]
+        assert "X-5.00" in sent and "Y7.00" in sent
+
+
+class TestExtruderJogAPI:
+    """Integration tests for POST /printers/{id}/extruder-jog (#1661).
+
+    Note: Bambu firmware enforces the cold-extrude guard
+    (min-temp refusal) so the route deliberately does not gate on temp.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=0")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("distance", [101, -200])
+    async def test_oversize_distance_rejected(self, async_client: AsyncClient, printer_factory, distance):
+        """Per-axis bound is 100mm; over-bound must be rejected."""
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance={distance}")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        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}/extruder-jog?distance=5")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extrude_uses_relative_e_mode(self, async_client: AsyncClient, printer_factory):
+        """Extruder jog must wrap with M83 (relative E) and restore M82 (absolute E)."""
+        printer = await printer_factory(name="P", model="X1C")
+        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}/extruder-jog?distance=5")
+        assert response.status_code == 200
+        sent = mock_client.send_gcode.call_args.args[0]
+        assert sent.startswith("M83\n")
+        assert sent.endswith("\nM82")
+        assert "E5.00" in sent
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_retract_uses_negative_distance(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        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}/extruder-jog?distance=-3.5")
+        assert response.status_code == 200
+        sent = mock_client.send_gcode.call_args.args[0]
+        assert "E-3.50" in sent

+ 99 - 0
backend/tests/unit/test_temperature_fan_presets.py

@@ -0,0 +1,99 @@
+"""Validator tests for the user-configurable temperature / fan presets."""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.settings import AppSettingsUpdate
+
+# (field_name, valid_payload, range_lo, range_hi)
+PRESET_FIELDS = [
+    ("nozzle_temp_presets", "[120, 220, 260]", 0, 320),
+    ("bed_temp_presets", "[55, 75, 90]", 0, 140),
+    ("chamber_temp_presets", "[35, 45, 60]", 0, 60),
+    ("fan_speed_presets", "[50, 75, 100]", 0, 100),
+]
+
+
+@pytest.mark.parametrize("field,valid,lo,hi", PRESET_FIELDS)
+def test_valid_triple_round_trips(field, valid, lo, hi):
+    update = AppSettingsUpdate(**{field: valid})
+    assert getattr(update, field) == valid
+
+
+@pytest.mark.parametrize("field,_valid,_lo,_hi", PRESET_FIELDS)
+def test_empty_string_means_use_defaults(field, _valid, _lo, _hi):
+    update = AppSettingsUpdate(**{field: ""})
+    assert getattr(update, field) == ""
+
+
+@pytest.mark.parametrize("field,_valid,_lo,_hi", PRESET_FIELDS)
+def test_missing_field_is_optional(field, _valid, _lo, _hi):
+    # Updates are PATCH-style; omitting the field shouldn't trigger the validator.
+    update = AppSettingsUpdate()
+    assert getattr(update, field) is None
+
+
+@pytest.mark.parametrize("field,_valid,_lo,_hi", PRESET_FIELDS)
+def test_malformed_json_rejected(field, _valid, _lo, _hi):
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(**{field: "not json"})
+    assert field in str(exc.value)
+
+
+@pytest.mark.parametrize("field,_valid,_lo,_hi", PRESET_FIELDS)
+def test_non_array_rejected(field, _valid, _lo, _hi):
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(**{field: '{"a": 1}'})
+    assert "array of exactly 3 integers" in str(exc.value)
+
+
+@pytest.mark.parametrize("field,_valid,_lo,_hi", PRESET_FIELDS)
+@pytest.mark.parametrize("bad_payload", ["[]", "[100]", "[100, 200]", "[100, 200, 300, 400]"])
+def test_wrong_length_rejected(field, _valid, _lo, _hi, bad_payload):
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(**{field: bad_payload})
+    assert "array of exactly 3 integers" in str(exc.value)
+
+
+@pytest.mark.parametrize("field,_valid,_lo,_hi", PRESET_FIELDS)
+def test_float_entries_rejected(field, _valid, _lo, _hi):
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(**{field: "[1.5, 2.0, 3.0]"})
+    assert "must all be integers" in str(exc.value)
+
+
+@pytest.mark.parametrize("field,_valid,_lo,_hi", PRESET_FIELDS)
+def test_string_entries_rejected(field, _valid, _lo, _hi):
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(**{field: '["120", "220", "260"]'})
+    assert "must all be integers" in str(exc.value)
+
+
+@pytest.mark.parametrize("field,_valid,lo,hi", PRESET_FIELDS)
+def test_below_range_rejected(field, _valid, lo, hi):
+    bad = f"[{lo - 1}, {lo}, {lo}]"
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(**{field: bad})
+    assert f"[{lo}, {hi}]" in str(exc.value)
+
+
+@pytest.mark.parametrize("field,_valid,lo,hi", PRESET_FIELDS)
+def test_above_range_rejected(field, _valid, lo, hi):
+    bad = f"[{hi}, {hi}, {hi + 1}]"
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(**{field: bad})
+    assert f"[{lo}, {hi}]" in str(exc.value)
+
+
+@pytest.mark.parametrize("field,_valid,lo,hi", PRESET_FIELDS)
+def test_range_bounds_inclusive(field, _valid, lo, hi):
+    """Both endpoints lo and hi must be accepted (inclusive bounds)."""
+    update = AppSettingsUpdate(**{field: f"[{lo}, {hi}, {hi}]"})
+    assert getattr(update, field) == f"[{lo}, {hi}, {hi}]"
+
+
+def test_booleans_rejected_even_though_python_treats_them_as_ints():
+    """`isinstance(True, int)` is True in Python — explicit guard required."""
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(nozzle_temp_presets="[true, 220, 260]")
+    assert "must all be integers" in str(exc.value)

+ 60 - 1
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -3,7 +3,7 @@
  */
  */
 
 
 import { describe, it, expect, beforeEach } from 'vitest';
 import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
 import { PrintersPage } from '../../pages/PrintersPage';
 import { PrintersPage } from '../../pages/PrintersPage';
@@ -182,6 +182,65 @@ describe('PrintersPage', () => {
         expect(screen.getAllByText(/25/)).toBeTruthy();
         expect(screen.getAllByText(/25/)).toBeTruthy();
       });
       });
     });
     });
+
+    it('sets left and right nozzle temperatures from the nozzle selector', async () => {
+      localStorage.setItem('printerCardSize', '2');
+      const temperatureRequests: Array<{ target: string | null; nozzle: string | null }> = [];
+      const dualNozzlePrinter = { ...mockPrinters[0], model: 'H2D', nozzle_count: 2 };
+      const dualNozzleStatus = {
+        ...mockPrinterStatus,
+        active_extruder: 0,
+        temperatures: {
+          ...mockPrinterStatus.temperatures,
+          nozzle: 31,
+          nozzle_target: 0,
+          nozzle_2: 32,
+          nozzle_2_target: 0,
+        },
+        nozzle_rack: [
+          { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+          { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+        ],
+      };
+
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([dualNozzlePrinter])),
+        http.get('/api/v1/printers/:id/status', () => HttpResponse.json(dualNozzleStatus)),
+        http.post('/api/v1/printers/:id/temperature/nozzle', ({ request }) => {
+          const url = new URL(request.url);
+          temperatureRequests.push({
+            target: url.searchParams.get('target'),
+            nozzle: url.searchParams.get('nozzle'),
+          });
+          return HttpResponse.json({ success: true, message: 'Nozzle temperature set' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('L / R')).toBeInTheDocument();
+      });
+
+      // Dual-nozzle temps live on the L/R temperature card, not the nozzle-select card.
+      fireEvent.click(screen.getByText('L / R').parentElement!);
+
+      const leftTempBox = screen.getByText('Left Temp').parentElement!.parentElement!;
+      fireEvent.click(within(leftTempBox).getByRole('button', { name: '220 C' }));
+
+      await waitFor(() => {
+        expect(temperatureRequests).toContainEqual({ target: '220', nozzle: '1' });
+      });
+
+      fireEvent.click(screen.getByText('L / R').parentElement!);
+
+      const rightTempBox = screen.getByText('Right Temp').parentElement!.parentElement!;
+      fireEvent.click(within(rightTempBox).getByRole('button', { name: '260 C' }));
+
+      await waitFor(() => {
+        expect(temperatureRequests).toContainEqual({ target: '260', nozzle: '0' });
+      });
+    });
   });
   });
 
 
   describe('empty state', () => {
   describe('empty state', () => {

+ 11 - 0
frontend/src/__tests__/utils/popoverPosition.test.ts

@@ -35,6 +35,17 @@ describe('computePopoverPosition (#1447)', () => {
     expect(pos.left).toBe(middleTrigger.right - 240); // 200
     expect(pos.left).toBe(middleTrigger.right - 240); // 200
   });
   });
 
 
+  it('can center-align the popover to the trigger', () => {
+    const pos = computePopoverPosition({
+      triggerRect: middleTrigger,
+      popoverWidth: 240,
+      estimatedHeight: 320,
+      horizontalAlign: 'center',
+      ...viewport,
+    });
+    expect(pos.left).toBe(300);
+  });
+
   it('flips above when the popover would overflow the bottom of the viewport', () => {
   it('flips above when the popover would overflow the bottom of the viewport', () => {
     // Trigger near the bottom — bottom=700 + gap 4 + height 320 = 1024 > 768.
     // Trigger near the bottom — bottom=700 + gap 4 + height 320 = 1024 > 768.
     const bottomTrigger = { top: 680, bottom: 700, left: 400, right: 440 };
     const bottomTrigger = { top: 680, bottom: 700, left: 400, right: 440 };

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

@@ -507,6 +507,8 @@ export interface PrinterStatus {
   awaiting_plate_clear: boolean;
   awaiting_plate_clear: boolean;
   // AMS drying support
   // AMS drying support
   supports_drying: boolean;
   supports_drying: boolean;
+  // Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
+  supports_chamber_heater?: boolean;
 }
 }
 
 
 export interface PrinterCreate {
 export interface PrinterCreate {
@@ -1176,6 +1178,12 @@ export interface AppSettings {
   require_plate_clear: boolean;
   require_plate_clear: boolean;
   // Shortest job first scheduling
   // Shortest job first scheduling
   queue_shortest_first: boolean;
   queue_shortest_first: boolean;
+  // User-configurable presets for the printer-card popovers (JSON arrays of 3 ints).
+  // Empty string = use built-in defaults.
+  nozzle_temp_presets: string;
+  bed_temp_presets: string;
+  chamber_temp_presets: string;
+  fan_speed_presets: string;
   // Default sidebar order (admin-set for all users)
   // Default sidebar order (admin-set for all users)
   default_sidebar_order: string;
   default_sidebar_order: string;
   // LDAP authentication
   // LDAP authentication
@@ -3553,6 +3561,32 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
+  setNozzleTemperature: (printerId: number, target: number, nozzle: number = 0) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/temperature/nozzle?target=${target}&nozzle=${nozzle}`,
+      { method: 'POST' }
+    ),
+
+  setBedTemperature: (printerId: number, target: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/temperature/bed?target=${target}`, {
+      method: 'POST',
+    }),
+
+  setChamberTemperature: (printerId: number, target: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/temperature/chamber?target=${target}`, {
+      method: 'POST',
+    }),
+
+  setFanSpeed: (printerId: number, fan: 'part' | 'aux' | 'chamber', speed: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/fan-speed?fan=${fan}&speed=${speed}`, {
+      method: 'POST',
+    }),
+
+  selectExtruder: (printerId: number, extruder: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/select-extruder?extruder=${extruder}`, {
+      method: 'POST',
+    }),
+
   setAirductMode: (printerId: number, mode: 'cooling' | 'heating') =>
   setAirductMode: (printerId: number, mode: 'cooling' | 'heating') =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/airduct-mode?mode=${mode}`, {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/airduct-mode?mode=${mode}`, {
       method: 'POST',
       method: 'POST',
@@ -3564,6 +3598,16 @@ export const api = {
       `/printers/${printerId}/bed-jog?distance=${distance}&force=${force}`,
       `/printers/${printerId}/bed-jog?distance=${distance}&force=${force}`,
       { method: 'POST' }
       { method: 'POST' }
     ),
     ),
+  xyJog: (printerId: number, x: number, y: number) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/xy-jog?x=${x}&y=${y}`,
+      { method: 'POST' }
+    ),
+  extruderJog: (printerId: number, distance: number) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/extruder-jog?distance=${distance}`,
+      { method: 'POST' }
+    ),
   homeAxes: (printerId: number, axes: 'z' | 'xy' | 'all' = 'z') =>
   homeAxes: (printerId: number, axes: 'z' | 'xy' | 'all' = 'z') =>
     request<{ success: boolean; message: string }>(
     request<{ success: boolean; message: string }>(
       `/printers/${printerId}/home-axes?axes=${axes}`,
       `/printers/${printerId}/home-axes?axes=${axes}`,
@@ -4411,6 +4455,10 @@ export const api = {
       ams_temp_good?: number;
       ams_temp_good?: number;
       ams_temp_fair?: number;
       ams_temp_fair?: number;
       bed_cooled_threshold?: number;
       bed_cooled_threshold?: number;
+      nozzle_temp_presets?: string;
+      bed_temp_presets?: string;
+      chamber_temp_presets?: string;
+      fan_speed_presets?: string;
     }>('/settings/ui-preferences'),
     }>('/settings/ui-preferences'),
   updateSettings: (data: AppSettingsUpdate) =>
   updateSettings: (data: AppSettingsUpdate) =>
     request<AppSettings>('/settings/', {
     request<AppSettings>('/settings/', {

+ 15 - 3
frontend/src/components/FilamentHoverCard.tsx

@@ -46,13 +46,14 @@ interface FilamentHoverCardProps {
   spoolman?: SpoolmanConfig;
   spoolman?: SpoolmanConfig;
   inventory?: InventoryConfig;
   inventory?: InventoryConfig;
   configureSlot?: ConfigureSlotConfig;
   configureSlot?: ConfigureSlotConfig;
+  actions?: ReactNode;
 }
 }
 
 
 /**
 /**
  * A hover card that displays filament details when hovering over AMS slots.
  * A hover card that displays filament details when hovering over AMS slots.
  * Replaces the basic browser tooltip with a styled popover.
  * Replaces the basic browser tooltip with a styled popover.
  */
  */
-export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) {
+export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot, actions }: FilamentHoverCardProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const navigate = useNavigate();
   const navigate = useNavigate();
   const [isVisible, setIsVisible] = useState(false);
   const [isVisible, setIsVisible] = useState(false);
@@ -434,6 +435,11 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   </button>
                   </button>
                 </div>
                 </div>
               )}
               )}
+              {actions && (
+                <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-1">
+                  {actions}
+                </div>
+              )}
             </div>
             </div>
           </div>
           </div>
 
 
@@ -499,6 +505,7 @@ interface EmptySlotHoverCardProps {
   className?: string;
   className?: string;
   configureSlot?: ConfigureSlotConfig;
   configureSlot?: ConfigureSlotConfig;
   onAssignSpool?: () => void;
   onAssignSpool?: () => void;
+  actions?: ReactNode;
   // #1322 follow-up: distinguish firmware-confirmed empty (state 9/10) from
   // #1322 follow-up: distinguish firmware-confirmed empty (state 9/10) from
   // a user reset where the firmware still has a spool registered. "reset"
   // a user reset where the firmware still has a spool registered. "reset"
   // surfaces the user-cleared label; undefined / "physical" keeps the
   // surfaces the user-cleared label; undefined / "physical" keeps the
@@ -506,7 +513,7 @@ interface EmptySlotHoverCardProps {
   kind?: 'physical' | 'reset';
   kind?: 'physical' | 'reset';
 }
 }
 
 
-export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool, kind }: EmptySlotHoverCardProps) {
+export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool, actions, kind }: EmptySlotHoverCardProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   const [isVisible, setIsVisible] = useState(false);
   // Screen-space coords for the portaled card — same pattern as
   // Screen-space coords for the portaled card — same pattern as
@@ -587,7 +594,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
               {kind === 'reset' ? t('ams.emptySlotReset') : t('ams.emptySlot')}
               {kind === 'reset' ? t('ams.emptySlotReset') : t('ams.emptySlot')}
             </div>
             </div>
             {/* Configure slot button */}
             {/* Configure slot button */}
-            {(configureSlot?.enabled || onAssignSpool) && (
+            {(configureSlot?.enabled || onAssignSpool || actions) && (
               <div className="px-2 pb-2 space-y-1">
               <div className="px-2 pb-2 space-y-1">
                 {configureSlot?.enabled && (
                 {configureSlot?.enabled && (
                   <button
                   <button
@@ -611,6 +618,11 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
                     {t('inventory.assignSpool')}
                     {t('inventory.assignSpool')}
                   </button>
                   </button>
                 )}
                 )}
+                {actions && (
+                  <div className="pt-1 mt-1 border-t border-bambu-dark-tertiary space-y-1">
+                    {actions}
+                  </div>
+                )}
               </div>
               </div>
             )}
             )}
           </div>
           </div>

+ 7 - 2
frontend/src/components/PrinterQueueWidget.tsx

@@ -11,9 +11,10 @@ interface PrinterQueueWidgetProps {
   printerModel?: string | null;
   printerModel?: string | null;
   loadedFilamentTypes?: Set<string>;
   loadedFilamentTypes?: Set<string>;
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
+  variant?: 'card' | 'panelExtension';
 }
 }
 
 
-export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, variant = 'card' }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: queue } = useQuery({
   const { data: queue } = useQuery({
     queryKey: ['queue', printerId, 'pending', printerModel],
     queryKey: ['queue', printerId, 'pending', printerModel],
@@ -36,10 +37,14 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
   // second button in this widget caused the two controls to overlap whenever
   // second button in this widget caused the two controls to overlap whenever
   // the plate-clear gate was up with auto-dispatch items queued — both POSTed
   // the plate-clear gate was up with auto-dispatch items queued — both POSTed
   // to the same /clear-plate endpoint, so the widget button was pure noise.
   // to the same /clear-plate endpoint, so the widget button was pure noise.
+  const linkClassName = variant === 'panelExtension'
+    ? 'block mt-2 border-t border-bambu-dark-tertiary pt-2 pl-1 hover:opacity-90 transition-opacity'
+    : 'block mb-3 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors';
+
   return (
   return (
     <Link
     <Link
       to="/queue"
       to="/queue"
-      className="block mb-3 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+      className={linkClassName}
     >
     >
       <div className="flex items-center justify-between gap-3">
       <div className="flex items-center justify-between gap-3">
         <div className="flex items-center gap-3 min-w-0 flex-1">
         <div className="flex items-center gap-3 min-w-0 flex-1">

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

@@ -361,7 +361,7 @@ export default {
       unload: 'Entladen',
       unload: 'Entladen',
     },
     },
     bedJog: {
     bedJog: {
-      title: 'Druckbett bewegen',
+      title: 'Jog-Steuerung',
       bed: 'Bett',
       bed: 'Bett',
       step: 'Schritt (mm)',
       step: 'Schritt (mm)',
       up: 'Platte hoch',
       up: 'Platte hoch',
@@ -1917,6 +1917,13 @@ export default {
     defaultTimelapseDesc: 'Zeitraffervideo aufnehmen',
     defaultTimelapseDesc: 'Zeitraffervideo aufnehmen',
     defaultNozzleOffsetCali: 'Düsenversatz-Kalibrierung',
     defaultNozzleOffsetCali: 'Düsenversatz-Kalibrierung',
     defaultNozzleOffsetCaliDesc: 'Düsenversatz zwischen Extrudern kalibrieren',
     defaultNozzleOffsetCaliDesc: 'Düsenversatz zwischen Extrudern kalibrieren',
+    tempFanPresetsTitle: 'Temperatur- und Lüfter-Vorgaben',
+    tempFanPresetsDescription: 'Passen Sie die Schnellwahlwerte in den Temperatur- und Lüfter-Popovers der Druckerkarte an. Die Aus-Schaltfläche wird immer angezeigt.',
+    tempFanPresetsNozzle: 'Düsentemperatur',
+    tempFanPresetsBed: 'Betttemperatur',
+    tempFanPresetsChamber: 'Kammertemperatur',
+    tempFanPresetsFan: 'Lüftergeschwindigkeit',
+    tempFanPresetsReset: 'Auf Standardwerte zurücksetzen',
     staggeredStart: 'Versetzter Start',
     staggeredStart: 'Versetzter Start',
     staggeredStartDescription: 'Standard-Gruppengröße und -Intervall beim Staffeln von Mehrdrucker-Batchstarts. Pro Batch im Druck-Dialog überschreibbar.',
     staggeredStartDescription: 'Standard-Gruppengröße und -Intervall beim Staffeln von Mehrdrucker-Batchstarts. Pro Batch im Druck-Dialog überschreibbar.',
     plateClear: 'Druckplatte-Bestätigung',
     plateClear: 'Druckplatte-Bestätigung',

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

@@ -361,7 +361,7 @@ export default {
       unload: 'Unload',
       unload: 'Unload',
     },
     },
     bedJog: {
     bedJog: {
-      title: 'Move build plate',
+      title: 'Jog Controls',
       bed: 'Bed',
       bed: 'Bed',
       step: 'Step (mm)',
       step: 'Step (mm)',
       up: 'Move plate up',
       up: 'Move plate up',
@@ -1927,6 +1927,13 @@ export default {
     defaultTimelapseDesc: 'Record timelapse video',
     defaultTimelapseDesc: 'Record timelapse video',
     defaultNozzleOffsetCali: 'Nozzle Offset Calibration',
     defaultNozzleOffsetCali: 'Nozzle Offset Calibration',
     defaultNozzleOffsetCaliDesc: 'Calibrate nozzle offsets between extruders',
     defaultNozzleOffsetCaliDesc: 'Calibrate nozzle offsets between extruders',
+    tempFanPresetsTitle: 'Temperature & Fan Presets',
+    tempFanPresetsDescription: 'Customize the quick-select values shown in printer-card temperature and fan-speed popovers. The Off button is always shown.',
+    tempFanPresetsNozzle: 'Nozzle temperature',
+    tempFanPresetsBed: 'Bed temperature',
+    tempFanPresetsChamber: 'Chamber temperature',
+    tempFanPresetsFan: 'Fan speed',
+    tempFanPresetsReset: 'Reset to defaults',
     staggeredStart: 'Staggered Start',
     staggeredStart: 'Staggered Start',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Plate-Clear Confirmation',
     plateClear: 'Plate-Clear Confirmation',

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

@@ -361,7 +361,7 @@ export default {
       unload: 'Descargar',
       unload: 'Descargar',
     },
     },
     bedJog: {
     bedJog: {
-      title: 'Mover la cama de impresión',
+      title: 'Controles de movimiento',
       bed: 'Cama',
       bed: 'Cama',
       step: 'Paso (mm)',
       step: 'Paso (mm)',
       up: 'Subir la cama',
       up: 'Subir la cama',
@@ -1920,6 +1920,13 @@ export default {
     defaultTimelapseDesc: 'Grabar vídeo time-lapse',
     defaultTimelapseDesc: 'Grabar vídeo time-lapse',
     defaultNozzleOffsetCali: 'Calibración del offset de boquillas',
     defaultNozzleOffsetCali: 'Calibración del offset de boquillas',
     defaultNozzleOffsetCaliDesc: 'Calibrar los desplazamientos entre boquillas',
     defaultNozzleOffsetCaliDesc: 'Calibrar los desplazamientos entre boquillas',
+    tempFanPresetsTitle: 'Predefinidos de temperatura y ventilador',
+    tempFanPresetsDescription: 'Personalice los valores de selección rápida en las ventanas emergentes de temperatura y velocidad del ventilador de la tarjeta de impresora. El botón Apagar siempre se muestra.',
+    tempFanPresetsNozzle: 'Temperatura de la boquilla',
+    tempFanPresetsBed: 'Temperatura de la cama',
+    tempFanPresetsChamber: 'Temperatura de la cámara',
+    tempFanPresetsFan: 'Velocidad del ventilador',
+    tempFanPresetsReset: 'Restablecer valores predeterminados',
     staggeredStart: 'Inicio escalonado',
     staggeredStart: 'Inicio escalonado',
     staggeredStartDescription: 'Tamaño de grupo e intervalo predeterminados al escalonar los inicios de lotes en varias impresoras. Se pueden anular por lote en la ventana de impresión.',
     staggeredStartDescription: 'Tamaño de grupo e intervalo predeterminados al escalonar los inicios de lotes en varias impresoras. Se pueden anular por lote en la ventana de impresión.',
     plateClear: 'Confirmación de cama despejada',
     plateClear: 'Confirmación de cama despejada',

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

@@ -361,7 +361,7 @@ export default {
       unload: 'Décharger',
       unload: 'Décharger',
     },
     },
     bedJog: {
     bedJog: {
-      title: 'Déplacer le plateau',
+      title: 'Commandes de déplacement',
       bed: 'Plateau',
       bed: 'Plateau',
       step: 'Pas (mm)',
       step: 'Pas (mm)',
       up: 'Monter le plateau',
       up: 'Monter le plateau',
@@ -1873,6 +1873,13 @@ export default {
     defaultTimelapseDesc: 'Enregistrer une vidéo timelapse',
     defaultTimelapseDesc: 'Enregistrer une vidéo timelapse',
     defaultNozzleOffsetCali: 'Calibration du décalage des buses',
     defaultNozzleOffsetCali: 'Calibration du décalage des buses',
     defaultNozzleOffsetCaliDesc: 'Calibrer les décalages entre les buses',
     defaultNozzleOffsetCaliDesc: 'Calibrer les décalages entre les buses',
+    tempFanPresetsTitle: 'Préréglages de température et de ventilateur',
+    tempFanPresetsDescription: 'Personnalisez les valeurs de sélection rapide affichées dans les fenêtres contextuelles de température et de vitesse du ventilateur de la carte imprimante. Le bouton Arrêt est toujours affiché.',
+    tempFanPresetsNozzle: 'Température de la buse',
+    tempFanPresetsBed: 'Température du plateau',
+    tempFanPresetsChamber: 'Température de la chambre',
+    tempFanPresetsFan: 'Vitesse du ventilateur',
+    tempFanPresetsReset: 'Rétablir les valeurs par défaut',
     staggeredStart: 'Démarrage échelonné',
     staggeredStart: 'Démarrage échelonné',
     staggeredStartDescription: 'Taille de groupe et intervalle par défaut lors de l\'échelonnement des démarrages de lots multi-imprimantes. Modifiable par lot dans la fenêtre d\'impression.',
     staggeredStartDescription: 'Taille de groupe et intervalle par défaut lors de l\'échelonnement des démarrages de lots multi-imprimantes. Modifiable par lot dans la fenêtre d\'impression.',
     plateClear: 'Confirmation de plateau libre',
     plateClear: 'Confirmation de plateau libre',

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

@@ -361,7 +361,7 @@ export default {
       unload: 'Scarica',
       unload: 'Scarica',
     },
     },
     bedJog: {
     bedJog: {
-      title: 'Muovi il piano di stampa',
+      title: 'Controlli jog',
       bed: 'Piano',
       bed: 'Piano',
       step: 'Passo (mm)',
       step: 'Passo (mm)',
       up: 'Sposta piano su',
       up: 'Sposta piano su',
@@ -1873,6 +1873,13 @@ export default {
     defaultTimelapseDesc: 'Registra un video timelapse',
     defaultTimelapseDesc: 'Registra un video timelapse',
     defaultNozzleOffsetCali: 'Calibrazione offset ugelli',
     defaultNozzleOffsetCali: 'Calibrazione offset ugelli',
     defaultNozzleOffsetCaliDesc: 'Calibra gli offset tra gli ugelli',
     defaultNozzleOffsetCaliDesc: 'Calibra gli offset tra gli ugelli',
+    tempFanPresetsTitle: 'Preset di temperatura e ventola',
+    tempFanPresetsDescription: 'Personalizza i valori di selezione rapida mostrati nei popover di temperatura e velocità ventola della scheda stampante. Il pulsante Off è sempre visibile.',
+    tempFanPresetsNozzle: 'Temperatura ugello',
+    tempFanPresetsBed: 'Temperatura piano',
+    tempFanPresetsChamber: 'Temperatura camera',
+    tempFanPresetsFan: 'Velocità ventola',
+    tempFanPresetsReset: 'Ripristina valori predefiniti',
     staggeredStart: 'Avvio scaglionato',
     staggeredStart: 'Avvio scaglionato',
     staggeredStartDescription: 'Dimensione gruppo e intervallo predefiniti per scaglionare avvii di batch multi-stampante. Sovrascrivibili per batch nella finestra di stampa.',
     staggeredStartDescription: 'Dimensione gruppo e intervallo predefiniti per scaglionare avvii di batch multi-stampante. Sovrascrivibili per batch nella finestra di stampa.',
     plateClear: 'Conferma piatto libero',
     plateClear: 'Conferma piatto libero',

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

@@ -360,7 +360,7 @@ export default {
       unload: 'アンロード',
       unload: 'アンロード',
     },
     },
     bedJog: {
     bedJog: {
-      title: 'ビルドプレートを移動',
+      title: 'ジョグ操作',
       bed: 'ベッド',
       bed: 'ベッド',
       step: 'ステップ (mm)',
       step: 'ステップ (mm)',
       up: 'プレートを上へ',
       up: 'プレートを上へ',
@@ -1916,6 +1916,13 @@ export default {
     defaultTimelapseDesc: 'タイムラプス動画を記録',
     defaultTimelapseDesc: 'タイムラプス動画を記録',
     defaultNozzleOffsetCali: 'ノズルオフセットキャリブレーション',
     defaultNozzleOffsetCali: 'ノズルオフセットキャリブレーション',
     defaultNozzleOffsetCaliDesc: 'エクストルーダー間のノズルオフセットを校正',
     defaultNozzleOffsetCaliDesc: 'エクストルーダー間のノズルオフセットを校正',
+    tempFanPresetsTitle: '温度・ファンプリセット',
+    tempFanPresetsDescription: 'プリンターカードの温度およびファン速度ポップオーバーに表示されるクイック選択値をカスタマイズします。オフボタンは常に表示されます。',
+    tempFanPresetsNozzle: 'ノズル温度',
+    tempFanPresetsBed: 'ベッド温度',
+    tempFanPresetsChamber: 'チャンバー温度',
+    tempFanPresetsFan: 'ファン速度',
+    tempFanPresetsReset: 'デフォルトにリセット',
     staggeredStart: '段階的開始',
     staggeredStart: '段階的開始',
     staggeredStartDescription: '複数プリンターのバッチ開始を段階的に行う際のデフォルトのグループサイズと間隔。プリントモーダルでバッチごとに上書き可能。',
     staggeredStartDescription: '複数プリンターのバッチ開始を段階的に行う際のデフォルトのグループサイズと間隔。プリントモーダルでバッチごとに上書き可能。',
     plateClear: 'プレートクリア確認',
     plateClear: 'プレートクリア確認',

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

@@ -331,7 +331,7 @@ export default {
       unload: '언로드'
       unload: '언로드'
     },
     },
     bedJog: {
     bedJog: {
-      title: '빌드 플레이트 이동',
+      title: '조그 컨트롤',
       bed: '베드',
       bed: '베드',
       step: '이동 거리 (mm)',
       step: '이동 거리 (mm)',
       up: '플레이트 위로',
       up: '플레이트 위로',
@@ -1799,6 +1799,13 @@ export default {
     defaultTimelapseDesc: '타임랩스 영상 녹화',
     defaultTimelapseDesc: '타임랩스 영상 녹화',
     defaultNozzleOffsetCali: '노즐 오프셋 보정',
     defaultNozzleOffsetCali: '노즐 오프셋 보정',
     defaultNozzleOffsetCaliDesc: '익스트루더 간 노즐 오프셋 보정',
     defaultNozzleOffsetCaliDesc: '익스트루더 간 노즐 오프셋 보정',
+    tempFanPresetsTitle: '온도 및 팬 프리셋',
+    tempFanPresetsDescription: '프린터 카드의 온도 및 팬 속도 팝오버에 표시되는 빠른 선택 값을 사용자 지정합니다. 끄기 버튼은 항상 표시됩니다.',
+    tempFanPresetsNozzle: '노즐 온도',
+    tempFanPresetsBed: '베드 온도',
+    tempFanPresetsChamber: '챔버 온도',
+    tempFanPresetsFan: '팬 속도',
+    tempFanPresetsReset: '기본값으로 재설정',
     staggeredStart: '엇갈린 시작',
     staggeredStart: '엇갈린 시작',
     staggeredStartDescription: '다중 프린터 일괄 시작 시 기본 그룹 크기 및 간격. 인쇄 모달에서 배치별로 재정의할 수 있습니다.',
     staggeredStartDescription: '다중 프린터 일괄 시작 시 기본 그룹 크기 및 간격. 인쇄 모달에서 배치별로 재정의할 수 있습니다.',
     plateClear: '플레이트 비움 확인',
     plateClear: '플레이트 비움 확인',

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

@@ -361,7 +361,7 @@ export default {
       unload: 'Descarregar',
       unload: 'Descarregar',
     },
     },
     bedJog: {
     bedJog: {
-      title: 'Mover a mesa de impressão',
+      title: 'Controles de movimento',
       bed: 'Mesa',
       bed: 'Mesa',
       step: 'Passo (mm)',
       step: 'Passo (mm)',
       up: 'Mover mesa para cima',
       up: 'Mover mesa para cima',
@@ -1873,6 +1873,13 @@ export default {
     defaultTimelapseDesc: 'Gravar vídeo timelapse',
     defaultTimelapseDesc: 'Gravar vídeo timelapse',
     defaultNozzleOffsetCali: 'Calibração de offset dos bicos',
     defaultNozzleOffsetCali: 'Calibração de offset dos bicos',
     defaultNozzleOffsetCaliDesc: 'Calibrar offsets entre extrusores',
     defaultNozzleOffsetCaliDesc: 'Calibrar offsets entre extrusores',
+    tempFanPresetsTitle: 'Predefinições de temperatura e ventilador',
+    tempFanPresetsDescription: 'Personalize os valores de seleção rápida exibidos nos popovers de temperatura e velocidade do ventilador do cartão da impressora. O botão Desligar sempre é exibido.',
+    tempFanPresetsNozzle: 'Temperatura do bico',
+    tempFanPresetsBed: 'Temperatura da mesa',
+    tempFanPresetsChamber: 'Temperatura da câmara',
+    tempFanPresetsFan: 'Velocidade do ventilador',
+    tempFanPresetsReset: 'Restaurar padrões',
     staggeredStart: 'Início escalonado',
     staggeredStart: 'Início escalonado',
     staggeredStartDescription: 'Tamanho de grupo e intervalo padrão ao escalonar inícios de lotes multi-impressora. Pode ser sobrescrito por lote no modal de impressão.',
     staggeredStartDescription: 'Tamanho de grupo e intervalo padrão ao escalonar inícios de lotes multi-impressora. Pode ser sobrescrito por lote no modal de impressão.',
     plateClear: 'Confirmação de placa livre',
     plateClear: 'Confirmação de placa livre',

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

@@ -361,7 +361,7 @@ export default {
       unload: 'Çıkar',
       unload: 'Çıkar',
     },
     },
     bedJog: {
     bedJog: {
-      title: 'Baskı plakasını hareket ettir',
+      title: 'Jog kontrolleri',
       bed: 'Tabla',
       bed: 'Tabla',
       step: 'Adım (mm)',
       step: 'Adım (mm)',
       up: 'Plakayı yukarı hareket ettir',
       up: 'Plakayı yukarı hareket ettir',
@@ -1920,6 +1920,13 @@ export default {
     defaultTimelapseDesc: 'Zaman atlamalı video kaydet',
     defaultTimelapseDesc: 'Zaman atlamalı video kaydet',
     defaultNozzleOffsetCali: 'Nozül Ofset Kalibrasyonu',
     defaultNozzleOffsetCali: 'Nozül Ofset Kalibrasyonu',
     defaultNozzleOffsetCaliDesc: 'Ekstrüderler arasındaki nozül ofsetlerini kalibre et',
     defaultNozzleOffsetCaliDesc: 'Ekstrüderler arasındaki nozül ofsetlerini kalibre et',
+    tempFanPresetsTitle: 'Sıcaklık ve Fan Ön Ayarları',
+    tempFanPresetsDescription: 'Yazıcı kartı sıcaklık ve fan hızı pencerelerinde gösterilen hızlı seçim değerlerini özelleştirin. Kapalı düğmesi her zaman gösterilir.',
+    tempFanPresetsNozzle: 'Nozül sıcaklığı',
+    tempFanPresetsBed: 'Tabla sıcaklığı',
+    tempFanPresetsChamber: 'Hazne sıcaklığı',
+    tempFanPresetsFan: 'Fan hızı',
+    tempFanPresetsReset: 'Varsayılanlara sıfırla',
     staggeredStart: 'Kademeli Başlatma',
     staggeredStart: 'Kademeli Başlatma',
     staggeredStartDescription: 'Çoklu yazıcı toplu başlatmaları kademelendirilirken varsayılan grup boyutu ve aralığı. Baskı modalinde yığın başına geçersiz kılınabilir.',
     staggeredStartDescription: 'Çoklu yazıcı toplu başlatmaları kademelendirilirken varsayılan grup boyutu ve aralığı. Baskı modalinde yığın başına geçersiz kılınabilir.',
     plateClear: 'Plaka Temizleme Onayı',
     plateClear: 'Plaka Temizleme Onayı',

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

@@ -361,7 +361,7 @@ export default {
       unload: '卸载',
       unload: '卸载',
     },
     },
     bedJog: {
     bedJog: {
-      title: '移动热床',
+      title: '点动控制',
       bed: '热床',
       bed: '热床',
       step: '步长 (mm)',
       step: '步长 (mm)',
       up: '热床上移',
       up: '热床上移',
@@ -1918,6 +1918,13 @@ export default {
     defaultTimelapseDesc: '录制延时摄影视频',
     defaultTimelapseDesc: '录制延时摄影视频',
     defaultNozzleOffsetCali: '喷嘴偏移校准',
     defaultNozzleOffsetCali: '喷嘴偏移校准',
     defaultNozzleOffsetCaliDesc: '校准两个挤出机之间的喷嘴偏移',
     defaultNozzleOffsetCaliDesc: '校准两个挤出机之间的喷嘴偏移',
+    tempFanPresetsTitle: '温度与风扇预设',
+    tempFanPresetsDescription: '自定义打印机卡片温度和风扇速度弹出框中显示的快速选择值。"关闭"按钮始终显示。',
+    tempFanPresetsNozzle: '喷嘴温度',
+    tempFanPresetsBed: '热床温度',
+    tempFanPresetsChamber: '腔室温度',
+    tempFanPresetsFan: '风扇速度',
+    tempFanPresetsReset: '恢复默认值',
     staggeredStart: '错峰启动',
     staggeredStart: '错峰启动',
     staggeredStartDescription: '错峰启动多台打印机批次时的默认组大小和间隔。可在打印对话框中按批次覆盖。',
     staggeredStartDescription: '错峰启动多台打印机批次时的默认组大小和间隔。可在打印对话框中按批次覆盖。',
     plateClear: '热床清空确认',
     plateClear: '热床清空确认',

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

@@ -361,7 +361,7 @@ export default {
       unload: '卸載',
       unload: '卸載',
     },
     },
     bedJog: {
     bedJog: {
-      title: '移動熱床',
+      title: '點動控制',
       bed: '熱床',
       bed: '熱床',
       step: '步長 (mm)',
       step: '步長 (mm)',
       up: '熱床上移',
       up: '熱床上移',
@@ -1918,6 +1918,13 @@ export default {
     defaultTimelapseDesc: '錄製縮時攝影影片',
     defaultTimelapseDesc: '錄製縮時攝影影片',
     defaultNozzleOffsetCali: '噴嘴偏移校準',
     defaultNozzleOffsetCali: '噴嘴偏移校準',
     defaultNozzleOffsetCaliDesc: '校準兩個擠出機之間的噴嘴偏移',
     defaultNozzleOffsetCaliDesc: '校準兩個擠出機之間的噴嘴偏移',
+    tempFanPresetsTitle: '溫度與風扇預設',
+    tempFanPresetsDescription: '自訂印表機卡片溫度和風扇速度彈出視窗中顯示的快速選擇值。「關閉」按鈕始終顯示。',
+    tempFanPresetsNozzle: '噴嘴溫度',
+    tempFanPresetsBed: '熱床溫度',
+    tempFanPresetsChamber: '腔室溫度',
+    tempFanPresetsFan: '風扇速度',
+    tempFanPresetsReset: '還原預設值',
     staggeredStart: '錯開啟動',
     staggeredStart: '錯開啟動',
     staggeredStartDescription: '多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。',
     staggeredStartDescription: '多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。',
     plateClear: '熱床清空確認',
     plateClear: '熱床清空確認',

Diferenças do arquivo suprimidas por serem muito extensas
+ 745 - 275
frontend/src/pages/PrintersPage.tsx


+ 79 - 0
frontend/src/pages/SettingsPage.tsx

@@ -7,6 +7,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { formatDateOnly } from '../utils/date';
 import { formatDateOnly } from '../utils/date';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
 import { checkPasswordComplexity } from '../utils/password';
 import { checkPasswordComplexity } from '../utils/password';
+import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets';
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
@@ -64,6 +65,7 @@ registerSettingsSearch({ labelKey: 'settings.smartPlugs', tab: 'plugs', keywords
 registerSettingsSearch({ labelKey: 'settings.providers', tab: 'notifications', keywords: 'telegram discord email notification providers webhook', anchor: 'card-providers' });
 registerSettingsSearch({ labelKey: 'settings.providers', tab: 'notifications', keywords: 'telegram discord email notification providers webhook', anchor: 'card-providers' });
 registerSettingsSearch({ labelKey: 'settings.messageTemplates', tab: 'notifications', keywords: 'message templates notification text edit', anchor: 'card-templates' });
 registerSettingsSearch({ labelKey: 'settings.messageTemplates', tab: 'notifications', keywords: 'message templates notification text edit', anchor: 'card-templates' });
 registerSettingsSearch({ labelKey: 'settings.defaultPrintOptions', labelFallback: 'Default Print Options', tab: 'queue', keywords: 'print bed leveling flow calibration vibration first layer timelapse', anchor: 'card-print-options' });
 registerSettingsSearch({ labelKey: 'settings.defaultPrintOptions', labelFallback: 'Default Print Options', tab: 'queue', keywords: 'print bed leveling flow calibration vibration first layer timelapse', anchor: 'card-print-options' });
+registerSettingsSearch({ labelKey: 'settings.tempFanPresetsTitle', labelFallback: 'Temperature & Fan Presets', tab: 'queue', keywords: 'temperature fan presets nozzle bed chamber quick buttons popover', anchor: 'card-temp-fan-presets' });
 registerSettingsSearch({ labelKey: 'settings.staggeredStart', labelFallback: 'Staggered Start', tab: 'queue', keywords: 'staggered batch delay start queue group', anchor: 'card-staggered' });
 registerSettingsSearch({ labelKey: 'settings.staggeredStart', labelFallback: 'Staggered Start', tab: 'queue', keywords: 'staggered batch delay start queue group', anchor: 'card-staggered' });
 registerSettingsSearch({ labelKey: 'settings.plateClear', labelFallback: 'Plate-Clear Confirmation', tab: 'queue', keywords: 'plate clear confirm auto queue', anchor: 'card-plate' });
 registerSettingsSearch({ labelKey: 'settings.plateClear', labelFallback: 'Plate-Clear Confirmation', tab: 'queue', keywords: 'plate clear confirm auto queue', anchor: 'card-plate' });
 registerSettingsSearch({ labelKey: 'settings.gcodeInjection', labelFallback: 'G-code Injection', tab: 'queue', keywords: 'gcode injection start end autoprint farmloop swapmod autoclear printflow', anchor: 'card-gcode' });
 registerSettingsSearch({ labelKey: 'settings.gcodeInjection', labelFallback: 'G-code Injection', tab: 'queue', keywords: 'gcode injection start end autoprint farmloop swapmod autoclear printflow', anchor: 'card-gcode' });
@@ -977,6 +979,10 @@ export function SettingsPage() {
       (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
       (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
       (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
       (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
       (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
       (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
+      (settings.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
+      (settings.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
+      (settings.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
+      (settings.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
       (settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
       (settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
 
 
     if (!hasChanges) {
     if (!hasChanges) {
@@ -1064,6 +1070,10 @@ export function SettingsPage() {
         stagger_group_size: localSettings.stagger_group_size,
         stagger_group_size: localSettings.stagger_group_size,
         stagger_interval_minutes: localSettings.stagger_interval_minutes,
         stagger_interval_minutes: localSettings.stagger_interval_minutes,
         require_plate_clear: localSettings.require_plate_clear,
         require_plate_clear: localSettings.require_plate_clear,
+        nozzle_temp_presets: localSettings.nozzle_temp_presets,
+        bed_temp_presets: localSettings.bed_temp_presets,
+        chamber_temp_presets: localSettings.chamber_temp_presets,
+        fan_speed_presets: localSettings.fan_speed_presets,
         session_max_hours: localSettings.session_max_hours,
         session_max_hours: localSettings.session_max_hours,
       };
       };
       updateMutation.mutate(settingsToSave);
       updateMutation.mutate(settingsToSave);
@@ -4026,6 +4036,75 @@ export function SettingsPage() {
             </CardContent>
             </CardContent>
           </Card>
           </Card>
 
 
+          {/* Temperature & Fan Presets */}
+          <Card id="card-temp-fan-presets">
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Thermometer className="w-4 h-4 text-bambu-green" />
+                {t('settings.tempFanPresetsTitle', 'Temperature & Fan Presets')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-xs text-bambu-gray">
+                {t('settings.tempFanPresetsDescription', 'Customize the quick-select values shown in printer-card temperature and fan-speed popovers. The Off button is always shown.')}
+              </p>
+              {PRESET_CATEGORIES.map(category => {
+                const raw = localSettings?.[category.key] ?? '';
+                const triple = parsePresetTriple(raw, category.defaults, category.lo, category.hi);
+                const unitLabel = category.unit === 'C' ? '°C' : '%';
+                const labelKeyMap = {
+                  nozzle_temp_presets: 'tempFanPresetsNozzle',
+                  bed_temp_presets: 'tempFanPresetsBed',
+                  chamber_temp_presets: 'tempFanPresetsChamber',
+                  fan_speed_presets: 'tempFanPresetsFan',
+                } as const;
+                const fallbackLabels = {
+                  nozzle_temp_presets: 'Nozzle temperature',
+                  bed_temp_presets: 'Bed temperature',
+                  chamber_temp_presets: 'Chamber temperature',
+                  fan_speed_presets: 'Fan speed',
+                } as const;
+                const rowLabel = t(`settings.${labelKeyMap[category.key]}`, fallbackLabels[category.key]);
+                return (
+                  <div key={category.key}>
+                    <div className="flex items-center justify-between mb-1">
+                      <label className="text-sm text-white">
+                        {rowLabel} <span className="text-bambu-gray text-xs">({unitLabel} · {category.lo}–{category.hi})</span>
+                      </label>
+                      <button
+                        type="button"
+                        onClick={() => updateSetting(category.key, '')}
+                        title={t('settings.tempFanPresetsReset', 'Reset to defaults')}
+                        className="text-bambu-gray hover:text-white transition-colors p-1 rounded hover:bg-bambu-dark-tertiary"
+                      >
+                        <RotateCcw className="w-3.5 h-3.5" />
+                      </button>
+                    </div>
+                    <div className="flex gap-2">
+                      {[0, 1, 2].map(idx => (
+                        <input
+                          key={idx}
+                          type="number"
+                          min={category.lo}
+                          max={category.hi}
+                          value={triple[idx]}
+                          onChange={(e) => {
+                            const next: [number, number, number] = [triple[0], triple[1], triple[2]];
+                            const parsedValue = parseInt(e.target.value, 10);
+                            const clamped = Math.max(category.lo, Math.min(category.hi, Number.isFinite(parsedValue) ? parsedValue : category.lo));
+                            next[idx] = clamped;
+                            updateSetting(category.key, JSON.stringify(next));
+                          }}
+                          className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                        />
+                      ))}
+                    </div>
+                  </div>
+                );
+              })}
+            </CardContent>
+          </Card>
+
           {/* Staggered Batch Start */}
           {/* Staggered Batch Start */}
           <Card id="card-staggered">
           <Card id="card-staggered">
             <CardHeader>
             <CardHeader>

+ 8 - 2
frontend/src/utils/popoverPosition.ts

@@ -30,6 +30,8 @@ export interface ComputePopoverPositionOpts {
   margin?: number;
   margin?: number;
   /** Gap between the trigger and the popover. */
   /** Gap between the trigger and the popover. */
   gap?: number;
   gap?: number;
+  /** Horizontal alignment relative to the trigger. Defaults to right-aligned. */
+  horizontalAlign?: 'right' | 'center';
 }
 }
 
 
 /**
 /**
@@ -65,6 +67,7 @@ export function computePopoverPosition(opts: ComputePopoverPositionOpts): Popove
     viewportWidth = window.innerWidth,
     viewportWidth = window.innerWidth,
     margin = 8,
     margin = 8,
     gap = 4,
     gap = 4,
+    horizontalAlign = 'right',
   } = opts;
   } = opts;
 
 
   // Vertical: prefer below, flip to above only when below overflows AND
   // Vertical: prefer below, flip to above only when below overflows AND
@@ -81,8 +84,11 @@ export function computePopoverPosition(opts: ComputePopoverPositionOpts): Popove
     }
     }
   }
   }
 
 
-  // Horizontal: right-align to trigger; clamp to viewport bounds.
-  let left = triggerRect.right - popoverWidth;
+  // Horizontal: align to trigger; clamp to viewport bounds.
+  const triggerCenter = triggerRect.left + ((triggerRect.right - triggerRect.left) / 2);
+  let left = horizontalAlign === 'center'
+    ? triggerCenter - (popoverWidth / 2)
+    : triggerRect.right - popoverWidth;
   if (left < margin) {
   if (left < margin) {
     left = margin;
     left = margin;
   } else if (left + popoverWidth > viewportWidth - margin) {
   } else if (left + popoverWidth > viewportWidth - margin) {

+ 71 - 0
frontend/src/utils/temperatureFanPresets.ts

@@ -0,0 +1,71 @@
+/**
+ * Helpers for the user-configurable temperature / fan-speed presets shown in
+ * the printer-card popovers and edited under Settings -> Workflow.
+ *
+ * Storage shape: JSON string of exactly 3 integers per category. Empty string
+ * means "use built-in defaults". Validators on the backend (AppSettingsUpdate)
+ * enforce the same shape on writes; helpers here are defensive parsers.
+ *
+ * The "Off" (value 0) button is rendered separately by buildPresetOptions and
+ * is not part of the configurable triple.
+ */
+
+export type PresetTriple = readonly [number, number, number];
+
+export const NOZZLE_TEMP_DEFAULTS: PresetTriple = [120, 220, 260];
+export const BED_TEMP_DEFAULTS: PresetTriple = [55, 75, 90];
+export const CHAMBER_TEMP_DEFAULTS: PresetTriple = [35, 45, 60];
+export const FAN_SPEED_DEFAULTS: PresetTriple = [50, 75, 100];
+
+export interface PresetCategory {
+  key: 'nozzle_temp_presets' | 'bed_temp_presets' | 'chamber_temp_presets' | 'fan_speed_presets';
+  defaults: PresetTriple;
+  lo: number;
+  hi: number;
+  unit: 'C' | '%';
+}
+
+export const PRESET_CATEGORIES: readonly PresetCategory[] = [
+  { key: 'nozzle_temp_presets', defaults: NOZZLE_TEMP_DEFAULTS, lo: 0, hi: 320, unit: 'C' },
+  { key: 'bed_temp_presets', defaults: BED_TEMP_DEFAULTS, lo: 0, hi: 140, unit: 'C' },
+  { key: 'chamber_temp_presets', defaults: CHAMBER_TEMP_DEFAULTS, lo: 0, hi: 60, unit: 'C' },
+  { key: 'fan_speed_presets', defaults: FAN_SPEED_DEFAULTS, lo: 0, hi: 100, unit: '%' },
+];
+
+/**
+ * Parse a JSON triple from settings, falling back to defaults if empty / malformed
+ * / wrong shape. Forward-compat for any storage drift; backend validators are
+ * the source of truth.
+ */
+export function parsePresetTriple(
+  raw: string | undefined | null,
+  fallback: PresetTriple,
+  lo: number,
+  hi: number,
+): [number, number, number] {
+  if (!raw) return [...fallback];
+  try {
+    const parsed = JSON.parse(raw);
+    if (
+      Array.isArray(parsed) &&
+      parsed.length === 3 &&
+      parsed.every((n) => Number.isInteger(n) && n >= lo && n <= hi)
+    ) {
+      return parsed as [number, number, number];
+    }
+  } catch {
+    // fall through to defaults
+  }
+  return [...fallback];
+}
+
+/** Build the popover options: fixed "Off" button + 3 user-configured presets. */
+export function buildPresetOptions(
+  values: PresetTriple,
+  unit: 'C' | '%',
+): Array<{ label: string; value: number }> {
+  return [
+    { label: 'Off', value: 0 },
+    ...values.map((v) => ({ label: `${v} ${unit}`, value: v })),
+  ];
+}

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff