Browse Source

Improve FTP upload progress and widen print modal

  FTP upload: reduce chunk size from 1MB to 64KB for smooth progress bar
  updates (~1s intervals instead of 20+ second gaps). Skip voidresp() for
  all printer models — H2D delays the 226 response by 30+ seconds after
  data transfer, causing a hang at 100%. Add transfer speed and TLS
  handshake timing to logs for diagnosing slow connections.

  Print/Schedule modal: widen from max-w-lg (512px) to max-w-2xl (672px)
  to accommodate long filament profile names like "PLA Support for PETG
  PETG Basic @Bambu Lab H2D 0.4 nozzle".
maziggy 2 months ago
parent
commit
6b92a99dd8

+ 2 - 0
CHANGELOG.md

@@ -55,6 +55,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
 - **Cleanup Obsolete Settings** — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g., `slicer_binary_path` from earlier slicer integration research).
 - **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.
+- **FTP Upload Progress & Speed** — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload `voidresp()` wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.
+- **Wider Print & Schedule Modals** — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle").
 
 ## [0.2.1] - 2026-02-27
 

+ 26 - 16
backend/app/services/bambu_ftp.py

@@ -4,6 +4,7 @@ import logging
 import os
 import socket
 import ssl
+import time
 from collections.abc import Awaitable, Callable
 from ftplib import FTP, FTP_TLS  # nosec B402
 from io import BytesIO
@@ -81,11 +82,10 @@ class BambuFTPClient:
     # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
     # These models have varying FTP SSL behavior depending on firmware version
     A1_MODELS = ("A1", "A1 Mini")
-    # Chunk size for manual upload transfer (1MB)
-    # Larger chunks reduce overhead and work better with A1 printers
-    CHUNK_SIZE = 1024 * 1024
-    # Per-chunk data socket timeout during upload.
-    UPLOAD_CHUNK_TIMEOUT = 120
+    # Chunk size for manual upload transfer (64KB)
+    # Smaller chunks provide smoother progress reporting — at typical printer FTP
+    # speeds (~50-100KB/s) this gives a progress update roughly every second.
+    CHUNK_SIZE = 64 * 1024
 
     # Cache for working FTP modes per printer IP
     # Maps IP -> "prot_p" or "prot_c"
@@ -368,11 +368,16 @@ class BambuFTPClient:
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
             with open(local_path, "rb") as f:
                 logger.debug("FTP STOR command starting for %s", remote_path)
+                t0 = time.monotonic()
                 conn = self._ftp.transfercmd(f"STOR {remote_path}")
+                logger.info(
+                    "FTP data channel ready in %.1fs (PASV + TLS handshake)",
+                    time.monotonic() - t0,
+                )
 
                 # Set explicit socket options for reliable transfer
                 conn.setblocking(True)
-                conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
+                conn.settimeout(self.timeout)
 
                 try:
                     while True:
@@ -408,14 +413,11 @@ class BambuFTPClient:
                     except OSError:
                         pass
 
-            # Skip voidresp() for A1 models — they hang after transfercmd uploads
-            if self.printer_model not in self.A1_MODELS:
-                try:
-                    self._ftp.voidresp()
-                except (OSError, ftplib.Error) as e:
-                    # Data transfer already completed — voidresp() failure is just a noisy
-                    # 226 acknowledgment issue, not an actual upload failure. Log and continue.
-                    logger.warning("FTP upload response for %s was not clean (data already sent): %s", remote_path, e)
+            # Skip voidresp() for all models — the data transfer is already complete.
+            # A1 models hang indefinitely on voidresp(). H2D printers (vsFTPd) delay
+            # the 226 response by 30+ seconds after data is fully sent. Even X1C/P1S
+            # gain nothing from waiting — the file is on the SD card once sendall() returns.
+            # Verified via direct curl upload: 226 arrives ~32s after data channel closes.
 
             if callback_exception is not None:
                 cleanup_ok = False
@@ -432,7 +434,15 @@ class BambuFTPClient:
                     f"Upload cancelled but failed to remove partial file {remote_path} from printer"
                 ) from callback_exception
 
-            logger.info("FTP upload complete: %s", remote_path)
+            elapsed = time.monotonic() - t0
+            speed_kbs = (file_size / 1024) / elapsed if elapsed > 0 else 0
+            logger.info(
+                "FTP upload complete: %s (%s bytes in %.1fs, %.0f KB/s)",
+                remote_path,
+                file_size,
+                elapsed,
+                speed_kbs,
+            )
             return True
         except ftplib.error_perm as e:
             # Permanent FTP error (4xx/5xx response)
@@ -462,7 +472,7 @@ class BambuFTPClient:
             # Use manual transfer instead of storbinary() for A1 compatibility
             conn = self._ftp.transfercmd(f"STOR {remote_path}")
             conn.setblocking(True)
-            conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
+            conn.settimeout(self.timeout)
 
             try:
                 # Send data in chunks

+ 29 - 48
backend/tests/unit/services/test_bambu_ftp.py

@@ -28,7 +28,7 @@ from backend.app.services.bambu_ftp import (
 )
 
 # Brief delay to allow pyftpdlib to flush uploaded files to disk.
-# Needed because upload_file() skips voidresp() for A1 compatibility,
+# Needed because upload_file() skips voidresp() for all models,
 # so the server may still be processing the data channel close event.
 _UPLOAD_FLUSH_DELAY = 0.3
 
@@ -306,8 +306,8 @@ class TestUpload:
         result = client.upload_file(local, "/cache/upload.3mf")
         assert result is True
         client.disconnect()
-        # Verify via fresh connection (upload_file skips voidresp()
-        # so the original session can't be reused for download)
+        # Verify via fresh connection (upload_file skips voidresp() for all
+        # models, so the original session can't be reused for download)
         time.sleep(_UPLOAD_FLUSH_DELAY)
         client2 = ftp_client_factory()
         client2.connect()
@@ -403,11 +403,11 @@ class TestUpload:
     def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):
         """Large file upload in chunks completes without error.
 
-        Uses 2.5MB to trigger multiple chunks with 1MB CHUNK_SIZE.
-        Content verification skipped because upload_file() doesn't call
-        voidresp() (for A1 compatibility), so the server may still be
-        flushing when we check. The upload result=True confirms the
-        client sent all chunks without error.
+        Uses 2.5MB to trigger multiple chunks with 64KB CHUNK_SIZE.
+        Content verification skipped because upload_file() skips
+        voidresp() for all models, so the server may still be flushing
+        when we check. The upload result=True confirms the client sent
+        all chunks without error.
         """
         content = b"C" * (1024 * 1024 * 2 + 512 * 1024)
         local = tmp_path / "large.bin"
@@ -422,8 +422,8 @@ class TestUpload:
         client.connect()
         result = client.upload_file(local, "/cache/large.bin", on_progress)
         assert result is True
-        # Verify multiple chunks were sent
-        assert len(progress_calls) >= 3  # 2.5MB / 1MB = at least 3 chunks
+        # Verify many chunks were sent (2.5MB / 64KB = 40 chunks)
+        assert len(progress_calls) >= 38
         assert progress_calls[-1][0] == len(content)
         client.disconnect()
 
@@ -871,46 +871,27 @@ class TestFailureScenarios:
         assert result2 == b"data after retry"
         client.disconnect()
 
-    def test_upload_succeeds_despite_voidresp_error(self, ftp_client_factory, ftp_server, tmp_path):
-        """Upload returns True even when voidresp() gets a non-clean response.
+    def test_upload_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
+        """Upload returns True without calling voidresp() for any model.
 
-        Regression: Previously, a voidresp() error after successful data transfer
-        returned False, which caused with_ftp_retry to re-upload the entire file
-        in a loop.
+        voidresp() is skipped for all models: A1 printers hang on it,
+        H2D printers delay the 226 response by 30+ seconds, and X1C/P1S
+        gain nothing from waiting. The file is on the SD card once
+        sendall() returns.
         """
         content = b"voidresp test data"
         local = tmp_path / "voidresp_test.3mf"
         local.write_bytes(content)
-        client = ftp_client_factory(printer_model="X1C")
-        client.connect()
-        result = client.upload_file(local, "/cache/voidresp_test.3mf")
-        assert result is True
-        client.disconnect()
-        # Verify the file is actually on the server
-        time.sleep(_UPLOAD_FLUSH_DELAY)
-        client2 = ftp_client_factory()
-        client2.connect()
-        downloaded = client2.download_file("/cache/voidresp_test.3mf")
-        assert downloaded == content
-        client2.disconnect()
-
-    def test_upload_a1_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
-        """A1 models skip voidresp() entirely and still return True.
-
-        Regression: A1 printers hang on voidresp() after transfercmd uploads.
-        """
-        content = b"A1 upload test"
-        local = tmp_path / "a1_test.3mf"
-        local.write_bytes(content)
-        client = ftp_client_factory(printer_model="A1")
-        client.connect()
-        result = client.upload_file(local, "/cache/a1_test.3mf")
-        assert result is True
-        client.disconnect()
-        # Verify the file is actually on the server
-        time.sleep(_UPLOAD_FLUSH_DELAY)
-        client2 = ftp_client_factory()
-        client2.connect()
-        downloaded = client2.download_file("/cache/a1_test.3mf")
-        assert downloaded == content
-        client2.disconnect()
+        for model in ("X1C", "A1", "H2D", None):
+            client = ftp_client_factory(printer_model=model)
+            client.connect()
+            result = client.upload_file(local, "/cache/voidresp_test.3mf")
+            assert result is True, f"Upload failed for model={model}"
+            client.disconnect()
+            # Verify the file is actually on the server
+            time.sleep(_UPLOAD_FLUSH_DELAY)
+            client2 = ftp_client_factory()
+            client2.connect()
+            downloaded = client2.download_file("/cache/voidresp_test.3mf")
+            assert downloaded == content, f"Content mismatch for model={model}"
+            client2.disconnect()

+ 1 - 1
frontend/src/components/PrintModal/index.tsx

@@ -657,7 +657,7 @@ export function PrintModal({
       onClick={isSubmitting ? undefined : onClose}
     >
       <Card
-        className="w-full max-w-lg max-h-[90vh] overflow-y-auto"
+        className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
         onClick={(e) => e.stopPropagation()}
       >
         <CardContent className={mode === 'reprint' ? '' : 'p-0'}>

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


+ 1 - 1
static/index.html

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

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