Просмотр исходного кода

Release the plate-clear gate on a powered-down printer (issue #2864)

    POST /printers/{id}/clear-plate answered 400 "Printer not connected" for
    anything without a live MQTT client, and the printer card hid the button
    under the same condition. With Auto Power Off that is the ordinary end of
    every print: the reporter's log has printer 1 marked offline at 12:00:55
    by the plug and the clear-plate POST rejected at 12:03:12, with the plate
    already cleared by hand. Nothing could release the gate short of powering
    each printer back on, clearing, and switching it off again.

    Nothing in the clear path talks to the printer. set_awaiting_plate_clear
    writes an in-memory set and the printers.awaiting_plate_clear column, and
    that column exists precisely so the gate survives an Auto Off cycle
    (#961). The guard came in with the endpoint in aa87e5598, copied from the
    stop/pause/resume handlers beside it, where reaching the printer is the
    whole point. The scheduler already reads the flag off a powered-off
    printer - it refuses to wake one that is still gated - and the wiki
    recommends the MQTT topic for automations because it does not depend on
    the printer being powered on. Only the write path disagreed.

    The card follows: showClearPlateButton drops the connection term, and the
    expanded-view button - which lived inside the block that renders nothing
    without a live status - is shared and given its own slot below it. The
    bulk filter tests clearPlate before the connection filter; every other
    bulk action still needs to reach the machine. The plate pill stays
    connected-only, since its only render site is inside that same block.

    Two things on the same path needed the flag without a client. GET /status
    returned the schema default for a printer with no cached state - manually
    disconnected, or not yet reconnected after a restart - reporting a clean
    plate the database disagreed with, and hiding the control on exactly the
    printers that needed it. And _emit_plate_clear_change bailed when the
    printer info cache was empty, which would have left the retained
    plate_clear topic (#2525) asserting "awaiting" after the gate was
    released; it now falls back to the row.

    This does not dispatch to an unreachable printer: _is_printer_idle still
    requires a connection. Releasing the gate is what lets the queue switch
    the printer on for the next job instead of passing it over.
maziggy 2 недель назад
Родитель
Сommit
f3c6e8ff26

Разница между файлами не показана из-за своего большого размера
+ 0 - 7
CHANGELOG.md


+ 12 - 2
backend/app/api/routes/printers.py

@@ -465,10 +465,16 @@ async def get_printer_status(
 
     state = printer_manager.get_status(printer_id)
     if not state:
+        # No MQTT client state — the printer was never connected this run, or it
+        # was disconnected manually. The plate-clear gate is Bambuddy-side and
+        # persisted, so it still has a truthful value here (#2864); reporting the
+        # schema default instead told clients the plate was clean and hid the
+        # only control that can release the gate.
         return PrinterStatus(
             id=printer_id,
             name=printer.name,
             connected=False,
+            awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         )
 
     # Determine cover URL if there's an active print (including paused)
@@ -3104,8 +3110,12 @@ async def clear_plate(
     if not printer:
         raise HTTPException(404, "Printer not found")
 
-    if not printer_manager.is_connected(printer_id):
-        raise HTTPException(400, "Printer not connected")
+    # Deliberately NOT gated on the printer being connected. Acknowledging the plate
+    # only mutates Bambuddy-side state — no MQTT command is sent — and with Auto Power
+    # Off the normal end-of-print state is exactly this: gate up, printer powered down.
+    # The guard this replaces was inherited from the sibling stop/pause/resume handlers,
+    # where reaching the printer IS required, and left farms with no way to release the
+    # gate short of powering each printer back on by hand (#2864).
 
     # Accept the acknowledgment whenever the printer is awaiting it — not only when the
     # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE

+ 22 - 1
backend/app/services/printer_manager.py

@@ -493,7 +493,14 @@ class PrinterManager:
         """
         printer = self.get_printer(printer_id)
         if not printer:
-            return
+            # No cached info means no client is registered — the printer was
+            # disconnected outright rather than merely powered off. The gate is
+            # still releasable from the API in that state (#2864), and a retained
+            # MQTT topic left saying "awaiting" would outlive the truth, so fall
+            # back to the row rather than dropping the emission.
+            printer = await self._printer_info_from_db(printer_id)
+            if not printer:
+                return
 
         try:
             from backend.app.services.mqtt_relay import mqtt_relay
@@ -516,6 +523,20 @@ class PrinterManager:
         except Exception as e:
             logger.warning("Failed to send plate-clear notification for printer %d: %s", printer_id, e)
 
+    async def _printer_info_from_db(self, printer_id: int) -> PrinterInfo | None:
+        """Name and serial for a printer with no registered client."""
+        from backend.app.core.database import async_session
+
+        try:
+            async with async_session() as db:
+                row = (
+                    await db.execute(select(Printer.name, Printer.serial_number).where(Printer.id == printer_id))
+                ).first()
+        except Exception as e:
+            logger.warning("Failed to load printer %d info from DB: %s", printer_id, e)
+            return None
+        return PrinterInfo(row[0], row[1]) if row else None
+
     async def _broadcast_status_change(self, printer_id: int) -> None:
         """Emit a ``printer_status`` WebSocket update for this printer (#1128).
 

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

@@ -4370,3 +4370,127 @@ class TestCoverWhenThePrintIsOnInternalStorage:
             await async_client.get(f"/api/v1/printers/{printer.id}/cover")
 
         mock_download.assert_called()
+
+
+class TestClearPlateOnAPoweredDownPrinter:
+    """Releasing the plate-clear gate must not require a reachable printer.
+
+    #2864: with Auto Power Off the end-of-print state is a dirty plate on a
+    machine Bambuddy has just switched off. The endpoint answered 400 for
+    anything not connected, so the operator who physically cleared that plate
+    had no way to say so — not from the API, not from the UI — and everything
+    gated on the flag stayed stuck until the printer was powered back on by
+    hand. Nothing in the clear path talks to the printer: the flag is
+    Bambuddy-side and persisted.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_plate_succeeds_while_disconnected(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        printer = await printer_factory(name="Powered-off X1C")
+        # What the smart-plug power-off leaves behind: the client is still
+        # registered, but its state is blanked to unknown/disconnected.
+        state = MagicMock(state="unknown", connected=False)
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.is_connected", return_value=False),
+            patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
+            patch(
+                "backend.app.api.routes.printers.printer_manager.is_awaiting_plate_clear",
+                return_value=True,
+            ),
+            patch("backend.app.api.routes.printers.printer_manager.set_awaiting_plate_clear") as mock_set,
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/clear-plate")
+
+        assert response.status_code == 200, response.text
+        mock_set.assert_called_once_with(printer.id, False)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_plate_succeeds_with_no_client_state_at_all(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """A printer disconnected manually, or never connected since the last
+        restart, has no client state — the persisted gate is still releasable."""
+        printer = await printer_factory(name="Never connected")
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.is_connected", return_value=False),
+            patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=None),
+            patch(
+                "backend.app.api.routes.printers.printer_manager.is_awaiting_plate_clear",
+                return_value=True,
+            ),
+            patch("backend.app.api.routes.printers.printer_manager.set_awaiting_plate_clear") as mock_set,
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/clear-plate")
+
+        assert response.status_code == 200, response.text
+        mock_set.assert_called_once_with(printer.id, False)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_plate_still_rejects_when_there_is_nothing_to_clear(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Dropping the connection guard must not turn the endpoint into a
+        no-op accept: an offline printer with a clean plate is still a 400."""
+        printer = await printer_factory(name="Powered-off, clean plate")
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.is_connected", return_value=False),
+            patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=None),
+            patch(
+                "backend.app.api.routes.printers.printer_manager.is_awaiting_plate_clear",
+                return_value=False,
+            ),
+            patch("backend.app.api.routes.printers.printer_manager.set_awaiting_plate_clear") as mock_set,
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/clear-plate")
+
+        assert response.status_code == 400, response.text
+        mock_set.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plate_clear_state_still_reaches_mqtt_without_a_client(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Clearing the gate on a printer with no registered client must still
+        update the retained MQTT topic (#2525) — otherwise it keeps telling Home
+        Assistant the plate is dirty after it was acknowledged."""
+        from backend.app.services.printer_manager import printer_manager
+
+        printer = await printer_factory(name="Disconnected P1S")
+
+        info = await printer_manager._printer_info_from_db(printer.id)
+
+        assert info is not None
+        assert info.name == "Disconnected P1S"
+        assert info.serial_number == printer.serial_number
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_reports_the_gate_without_client_state(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Without this the schema default reported a clean plate for a printer
+        the DB says is still gated, and the UI hid the control that releases it."""
+        printer = await printer_factory(name="No client state")
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=None),
+            patch(
+                "backend.app.api.routes.printers.printer_manager.is_awaiting_plate_clear",
+                return_value=True,
+            ),
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert response.status_code == 200, response.text
+        body = response.json()
+        assert body["connected"] is False
+        assert body["awaiting_plate_clear"] is True

+ 20 - 0
backend/tests/unit/test_plate_clear_mqtt_notification.py

@@ -234,6 +234,26 @@ class TestEmitFanOut:
 
         publish.assert_not_awaited()
 
+    @pytest.mark.asyncio
+    async def test_falls_back_to_the_db_when_no_client_is_registered(self):
+        """A printer disconnected outright has no cached info, but the gate can
+        still be released through the API (#2864) — the retained topic must not
+        be left asserting a state that is no longer true."""
+        manager = PrinterManager()
+
+        publish = AsyncMock()
+        with (
+            patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
+            patch.object(
+                PrinterManager,
+                "_printer_info_from_db",
+                AsyncMock(return_value=SimpleNamespace(name="Powered-off X1C", serial_number="01P00A000000009")),
+            ),
+        ):
+            await manager._emit_plate_clear_change(9, False)
+
+        publish.assert_awaited_once_with(9, "Powered-off X1C", "01P00A000000009", False)
+
     @pytest.mark.asyncio
     async def test_mqtt_failure_does_not_block_the_notification(self):
         manager = PrinterManager()

+ 41 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -496,6 +496,47 @@ describe('PrintersPage', () => {
       expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
     });
 
+    it('offers the clear action on a powered-down printer (#2864)', async () => {
+      // Auto Power Off leaves exactly this: gate up, printer unreachable. The
+      // control used to be hidden here, so a plate cleared by hand could not be
+      // acknowledged until the printer was powered back on.
+      let awaitingPlateClear = true;
+      let cleared = false;
+
+      server.use(
+        http.get('/api/v1/printers/', () => {
+          return HttpResponse.json([mockPrinters[0]]);
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({
+            ...mockPrinterStatus,
+            connected: false,
+            state: 'unknown',
+            awaiting_plate_clear: awaitingPlateClear,
+          });
+        }),
+        http.post('/api/v1/printers/:id/clear-plate', () => {
+          cleared = true;
+          awaitingPlateClear = false;
+          return HttpResponse.json({ success: true, message: 'Plate cleared' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      const clearButton = await screen.findByRole('button', { name: 'Mark plate as cleared' });
+
+      fireEvent.click(clearButton);
+
+      await waitFor(() => {
+        expect(cleared).toBe(true);
+      });
+
+      await waitFor(() => {
+        expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+      });
+    });
+
     it('updates the plate clear status after using the printer card action', async () => {
       let awaitingPlateClear = true;
 

+ 40 - 18
frontend/src/pages/PrintersPage.tsx

@@ -2615,7 +2615,10 @@ function PrinterCard({
   const lastPrint = lastPrints?.[0];
   const isPrintingOrPaused = status?.state === 'RUNNING' || status?.state === 'PAUSE';
   const needsPlateClear = requirePlateClear && status?.awaiting_plate_clear === true;
-  const showClearPlateButton = status?.connected && needsPlateClear && !isPrintingOrPaused;
+  // Not gated on `connected`: the plate-clear gate is Bambuddy-side state, and with
+  // Auto Power Off the printer is powered down exactly when the operator clears the
+  // plate. Hiding the control there left no way to release the gate (#2864).
+  const showClearPlateButton = needsPlateClear && !isPrintingOrPaused;
   const activePrintName = status?.current_print && isPrintingOrPaused
     ? formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel)
     : null;
@@ -2628,6 +2631,9 @@ function PrinterCard({
     }
   }, [activePrintName, needsPlateClear, status?.cover_url]);
   const plateStatus = (() => {
+    // Connected-only because the pill's only render site sits inside the live-status
+    // panel. For a powered-down printer the plate-clear button itself carries the
+    // state — see the standalone slot below the status block (#2864).
     if (!requirePlateClear || !status?.connected) return null;
     if (isPrintingOrPaused) {
       return {
@@ -2825,6 +2831,26 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
   });
 
+  // Rendered from two places: inside the live-status block for a connected printer,
+  // and standalone below it for a powered-down one, whose status block isn't rendered
+  // at all (#2864). Shared so the two can't drift apart.
+  const expandedClearPlateButton = (
+    <button
+      type="button"
+      onClick={() => clearPlateMutation.mutate()}
+      disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
+      className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-400/40 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
+      title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
+    >
+      {clearPlateMutation.isPending ? (
+        <Loader2 className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] animate-spin" />
+      ) : (
+        <PlateClearedIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+      )}
+      {t('printers.plateStatus.markCleared')}
+    </button>
+  );
+
   const nozzleTemperatureMutation = useMutation({
     mutationFn: ({ target, nozzle }: { target: number; nozzle: number }) =>
       api.setNozzleTemperature(printer.id, target, nozzle),
@@ -4692,22 +4718,7 @@ function PrinterCard({
               );
             })()}
 
-            {viewMode === 'expanded' && showClearPlateButton && (
-              <button
-                type="button"
-                onClick={() => clearPlateMutation.mutate()}
-                disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
-                className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-400/40 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
-                title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
-              >
-                {clearPlateMutation.isPending ? (
-                  <Loader2 className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] animate-spin" />
-                ) : (
-                  <PlateClearedIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
-                )}
-                {t('printers.plateStatus.markCleared')}
-              </button>
-            )}
+            {viewMode === 'expanded' && showClearPlateButton && expandedClearPlateButton}
 
             {/* Controls */}
             {viewMode === 'expanded' && (() => {
@@ -6285,6 +6296,14 @@ function PrinterCard({
           </>
         )}
 
+        {/* Powered-down printer with a dirty plate: the status block above renders
+            nothing without a live connection, so the plate-clear control gets its own
+            slot here. Auto Power Off makes this the ordinary end-of-print state, and
+            the gate is Bambuddy-side — releasing it never touches the printer (#2864). */}
+        {printer.is_active !== false && !status?.connected && viewMode === 'expanded' && showClearPlateButton && (
+          expandedClearPlateButton
+        )}
+
         {/* Bottom block (power row + action bar). Wrapped together so the
             power row hugs the action bar at the card bottom instead of
             floating up when there's less filament content above. */}
@@ -8895,12 +8914,15 @@ export function PrintersPage() {
     // Filter to only applicable printers based on cached state
     const applicableIds = ids.filter(id => {
       const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', id]);
+      // clearPlate is checked before the connection filter: it only releases a
+      // Bambuddy-side gate, so it applies to a printer Auto Power Off has shut
+      // down — every other action here needs to reach the machine (#2864).
+      if (action === 'clearPlate') return !!(status as { awaiting_plate_clear?: boolean } | undefined)?.awaiting_plate_clear;
       if (!status?.connected) return false;
       switch (action) {
         case 'stop': return status.state === 'RUNNING' || status.state === 'PAUSE';
         case 'pause': return status.state === 'RUNNING';
         case 'resume': return status.state === 'PAUSE';
-        case 'clearPlate': return !!(status as { awaiting_plate_clear?: boolean }).awaiting_plate_clear;
         case 'clearHMS': return status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0;
         default: return false;
       }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-pHbVkL6v.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CXdmGala.js"></script>
+    <script type="module" crossorigin src="/assets/index-pHbVkL6v.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_NRLB80.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов