Browse Source

feat: add clear HMS errors button to dismiss stale print errors

Add a "Clear Errors" button to the HMS error modal that sends
clean_print_error via MQTT and locally clears hms_errors for
immediate UI feedback. Useful for dismissing stale print_error
values that persist after print cancellation or transient events.
maziggy 3 months ago
parent
commit
bc15d49a7e

+ 1 - 0
CHANGELOG.md

@@ -18,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
 - **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
 - **Notification Thumbnails for Telegram & ntfy** ([#372](https://github.com/maziggy/bambuddy/issues/372)) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the `sendPhoto` API with the image as caption attachment. ntfy sends the image as a binary PUT with `Filename` and `Message` headers. No configuration needed — images are sent automatically when available.
+- **Clear HMS Errors** — New "Clear Errors" button in the HMS error modal sends a `clean_print_error` MQTT command to dismiss stale `print_error` values that persist after print cancellation or transient events. Locally clears the error list for immediate UI feedback. Permission-gated to `printers:control`. The button only appears when there are active errors.
 
 ### Fixed
 - **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.

+ 1 - 1
README.md

@@ -91,7 +91,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - AMS slot RFID re-read
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - Dual external spool support for H2D (Ext-L / Ext-R)
-- HMS error monitoring with history
+- HMS error monitoring with history and clear errors
 - Print success rates & trends
 - Filament usage tracking
 - Cost analytics & failure analysis

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

@@ -1958,6 +1958,29 @@ async def set_chamber_light(
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
 
 
+@router.post("/{printer_id}/hms/clear")
+async def clear_hms_errors(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Clear HMS/print errors on the printer."""
+    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.clear_hms_errors()
+    if not success:
+        raise HTTPException(500, "Failed to clear HMS errors")
+
+    return {"success": True, "message": "HMS errors cleared"}
+
+
 @router.get("/{printer_id}/print/objects")
 async def get_printable_objects(
     printer_id: int,

+ 12 - 0
backend/app/services/bambu_mqtt.py

@@ -2861,6 +2861,18 @@ class BambuMQTTClient:
         logger.info("[%s] Sent resume print command", self.serial_number)
         return True
 
+    def clear_hms_errors(self) -> bool:
+        """Clear HMS/print errors on the printer and locally."""
+        if not self._client or not self.state.connected:
+            logger.warning("[%s] Cannot clear HMS errors: not connected", self.serial_number)
+            return False
+
+        command = {"print": {"command": "clean_print_error", "sequence_id": "0"}}
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        self.state.hms_errors = []
+        logger.info("[%s] Sent clear HMS errors command", self.serial_number)
+        return True
+
     def skip_objects(self, object_ids: list[int]) -> bool:
         """Skip specific objects during a print.
 

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

@@ -887,3 +887,65 @@ class TestChamberLightAPI:
 
             assert response.status_code == 500
             assert "failed" in response.json()["detail"].lower()
+
+
+class TestClearHMSErrorsAPI:
+    """Integration tests for clear HMS errors endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/hms/clear")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        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}/hms/clear")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful clear HMS errors request."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.clear_hms_errors.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}/hms/clear")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert "cleared" in result["message"].lower()
+            mock_client.clear_hms_errors.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify error handling when clear HMS errors fails."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.clear_hms_errors.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}/hms/clear")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()

+ 146 - 0
frontend/src/__tests__/components/HMSErrorModal.test.tsx

@@ -0,0 +1,146 @@
+/**
+ * Tests for the HMSErrorModal component.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { HMSErrorModal } from '../../components/HMSErrorModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import type { HMSError } from '../../api/client';
+
+// Error code 0300_400C = "The task was canceled." (known code in the database)
+const knownError: HMSError = {
+  attr: 0x0300,
+  code: '0x400C',
+  severity: 2,
+};
+
+// Error code FFFF_FFFF = unknown (not in the database)
+const unknownError: HMSError = {
+  attr: 0xFFFF,
+  code: '0xFFFF',
+  severity: 1,
+};
+
+describe('HMSErrorModal', () => {
+  const defaultProps = {
+    printerName: 'Test Printer',
+    errors: [knownError],
+    onClose: vi.fn(),
+    printerId: 1,
+    hasPermission: vi.fn().mockReturnValue(true) as unknown as (permission: 'printers:control') => boolean,
+  };
+
+  afterEach(() => {
+    cleanup();
+    vi.clearAllMocks();
+  });
+
+  describe('rendering', () => {
+    it('renders the modal title with printer name', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('Errors - Test Printer')).toBeInTheDocument();
+    });
+
+    it('shows error description for known error codes', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('The task was canceled.')).toBeInTheDocument();
+    });
+
+    it('shows no errors message when all errors are unknown', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
+      expect(screen.getByText('No errors')).toBeInTheDocument();
+    });
+
+    it('shows no errors message when errors array is empty', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[]} />);
+      expect(screen.getByText('No errors')).toBeInTheDocument();
+    });
+  });
+
+  describe('clear errors button', () => {
+    it('shows clear button when there are known errors', () => {
+      render(<HMSErrorModal {...defaultProps} />);
+      expect(screen.getByText('Clear Errors')).toBeInTheDocument();
+    });
+
+    it('hides clear button when there are no known errors', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[]} />);
+      expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
+    });
+
+    it('hides clear button when all errors are unknown codes', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);
+      expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();
+    });
+
+    it('disables clear button when user lacks permission', () => {
+      const noPermission = vi.fn().mockReturnValue(false) as unknown as (permission: 'printers:control') => boolean;
+      render(<HMSErrorModal {...defaultProps} hasPermission={noPermission} />);
+      expect(screen.getByText('Clear Errors').closest('button')).toBeDisabled();
+    });
+
+    it('calls API and closes modal on successful clear', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+
+      server.use(
+        http.post('/api/v1/printers/1/hms/clear', () => {
+          return HttpResponse.json({ success: true, message: 'HMS errors cleared' });
+        })
+      );
+
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      await user.click(screen.getByText('Clear Errors'));
+
+      await waitFor(() => {
+        expect(onClose).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    it('shows error toast on failed clear', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+
+      server.use(
+        http.post('/api/v1/printers/1/hms/clear', () => {
+          return HttpResponse.json({ detail: 'Failed' }, { status: 500 });
+        })
+      );
+
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      await user.click(screen.getByText('Clear Errors'));
+
+      await waitFor(() => {
+        expect(onClose).not.toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('interactions', () => {
+    it('calls onClose when X button is clicked', async () => {
+      const user = userEvent.setup();
+      const onClose = vi.fn();
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      // The X button is the button with the X icon in the header
+      const closeButtons = screen.getAllByRole('button');
+      // First button is the X close button in the header
+      await user.click(closeButtons[0]);
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+
+    it('calls onClose when Escape key is pressed', () => {
+      const onClose = vi.fn();
+      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(onClose).toHaveBeenCalledTimes(1);
+    });
+  });
+});

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

@@ -2344,6 +2344,10 @@ export const api = {
       }
     ),
 
+  // HMS Errors
+  clearHMSErrors: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/hms/clear`, { method: 'POST' }),
+
   // AMS Control
   refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
     request<{ success: boolean; message: string }>(

+ 34 - 6
frontend/src/components/HMSErrorModal.tsx

@@ -2,13 +2,18 @@
 // Source: https://github.com/greghesp/ha-bambulab
 import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
-import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
-import type { HMSError } from '../api/client';
+import { useMutation } from '@tanstack/react-query';
+import { X, AlertTriangle, AlertCircle, Info, ExternalLink, Loader2, Trash2 } from 'lucide-react';
+import type { HMSError, Permission } from '../api/client';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
 
 interface HMSErrorModalProps {
   printerName: string;
   errors: HMSError[];
   onClose: () => void;
+  printerId: number;
+  hasPermission: (permission: Permission) => boolean;
 }
 
 // Comprehensive error code database (short format: XXXX_YYYY)
@@ -904,11 +909,20 @@ function getHMSHomeUrl(): string {
   return `https://wiki.bambulab.com/en/hms/home`;
 }
 
-export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
+export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission }: HMSErrorModalProps) {
   const { t } = useTranslation();
+  const { showToast } = useToast();
 
-  // Debug: log errors to see what data we're receiving
-  console.log('HMSErrorModal errors:', JSON.stringify(errors, null, 2));
+  const clearMutation = useMutation({
+    mutationFn: () => api.clearHMSErrors(printerId),
+    onSuccess: () => {
+      showToast(t('hmsErrors.clearSuccess'), 'success');
+      onClose();
+    },
+    onError: () => {
+      showToast(t('hmsErrors.clearFailed'), 'error');
+    },
+  });
 
   // Filter to only show errors we have descriptions for (skip unknown codes)
   const knownErrors = errors.filter((error) => {
@@ -994,10 +1008,24 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
         </div>
 
         {/* Footer */}
-        <div className="p-4 border-t border-bambu-dark-tertiary">
+        <div className="p-4 border-t border-bambu-dark-tertiary flex items-center justify-between gap-3">
           <p className="text-xs text-bambu-gray">
             {t('hmsErrors.clearInstructions')}
           </p>
+          {knownErrors.length > 0 && (
+            <button
+              onClick={() => clearMutation.mutate()}
+              disabled={!hasPermission('printers:control') || clearMutation.isPending}
+              className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
+            >
+              {clearMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Trash2 className="w-4 h-4" />
+              )}
+              {t('hmsErrors.clearErrors')}
+            </button>
+          )}
         </div>
       </div>
     </div>

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

@@ -1603,6 +1603,9 @@ export default {
     noErrors: 'Keine Fehler',
     viewOnWiki: 'Im Bambu Lab Wiki ansehen',
     clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
+    clearErrors: 'Fehler löschen',
+    clearSuccess: 'HMS-Fehler gelöscht',
+    clearFailed: 'HMS-Fehler konnten nicht gelöscht werden',
   },
 
   // MQTT Debug modal

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

@@ -1603,6 +1603,9 @@ export default {
     noErrors: 'No errors',
     viewOnWiki: 'View on Bambu Lab Wiki',
     clearInstructions: 'Clear errors on the printer to dismiss them here.',
+    clearErrors: 'Clear Errors',
+    clearSuccess: 'HMS errors cleared',
+    clearFailed: 'Failed to clear HMS errors',
   },
 
   // MQTT Debug modal

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

@@ -1599,6 +1599,9 @@ export default {
     noErrors: 'Aucune erreur',
     viewOnWiki: 'Voir sur le Wiki Bambu Lab',
     clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
+    clearErrors: 'Effacer les erreurs',
+    clearSuccess: 'Erreurs HMS effacées',
+    clearFailed: 'Échec de l\'effacement des erreurs HMS',
   },
 
   // MQTT Debug modal

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

@@ -1432,6 +1432,9 @@ export default {
     noErrors: 'Nessun errore',
     viewOnWiki: 'Vedi su Bambu Lab Wiki',
     clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
+    clearErrors: 'Cancella errori',
+    clearSuccess: 'Errori HMS cancellati',
+    clearFailed: 'Impossibile cancellare gli errori HMS',
   },
 
   // MQTT Debug modal

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

@@ -2941,6 +2941,9 @@ export default {
     noErrors: 'エラーなし',
     viewOnWiki: 'Bambu Lab Wikiで表示',
     clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
+    clearErrors: 'エラーをクリア',
+    clearSuccess: 'HMSエラーをクリアしました',
+    clearFailed: 'HMSエラーのクリアに失敗しました',
   },
   plateAlert: {
     title: '印刷が一時停止されました!',

+ 2 - 0
frontend/src/pages/PrintersPage.tsx

@@ -3972,6 +3972,8 @@ function PrinterCard({
           printerName={printer.name}
           errors={status?.hms_errors || []}
           onClose={() => setShowHMSModal(false)}
+          printerId={printer.id}
+          hasPermission={hasPermission}
         />
       )}
 

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BstMPBCa.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-D7b3EUDG.css">
+    <script type="module" crossorigin src="/assets/index-DGpIc0xD.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C2PHjTQb.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff