Skip to content

Commit 23e7283

Browse files
author
Your Name
committed
Port fix-prints FTP 3MF download logic
1 parent a623049 commit 23e7283

1 file changed

Lines changed: 225 additions & 87 deletions

File tree

tools_3mf.py

Lines changed: 225 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,43 @@
22
import zipfile
33
import tempfile
44
import xml.etree.ElementTree as ET
5-
import pycurl
6-
import urllib.parse
5+
import ftplib
6+
import socket
7+
import ssl
78
import os
89
import re
910
import time
10-
import io
1111
from datetime import datetime
1212
from config import PRINTER_CODE, PRINTER_IP
1313
from urllib.parse import urlparse
1414
from logger import log
1515

16+
class ImplicitFTP_TLS(ftplib.FTP_TLS):
17+
"""
18+
FTP_TLS subclass that wraps sockets for implicit FTPS (port 990).
19+
Adapted from HA's ha-bambulab implementation.
20+
"""
21+
def __init__(self, *args, **kwargs):
22+
super().__init__(*args, **kwargs)
23+
self._sock = None
24+
25+
@property
26+
def sock(self):
27+
return self._sock
28+
29+
@sock.setter
30+
def sock(self, value):
31+
if value is not None and not isinstance(value, ssl.SSLSocket):
32+
value = self.context.wrap_socket(value)
33+
self._sock = value
34+
35+
def ntransfercmd(self, cmd, rest=None):
36+
conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
37+
if self._prot_p:
38+
session = self.sock.session if isinstance(self.sock, ssl.SSLSocket) else None
39+
conn = self.context.wrap_socket(conn, server_hostname=self.host, session=session)
40+
return conn, size
41+
1642
def parse_ftp_listing(line):
1743
"""Parse a line from an FTP LIST command."""
1844
parts = line.split(maxsplit=8)
@@ -65,115 +91,227 @@ def download3mfFromCloud(url, destFile):
6591
response.raise_for_status()
6692
destFile.write(response.content)
6793

94+
def ensure_ftps_connection(ftp_host, ftp_user, ftp_pass, connect_retries, timeout=15):
95+
"""Create an implicit FTPS connection with retries."""
96+
context = ssl.create_default_context()
97+
context.check_hostname = False
98+
context.verify_mode = ssl.CERT_NONE
99+
100+
for attempt in range(1, connect_retries + 1):
101+
ftp = ImplicitFTP_TLS(context=context, timeout=timeout)
102+
try:
103+
log(f"[DEBUG] FTP connection check ({attempt}/{connect_retries})...")
104+
ftp.connect(host=ftp_host, port=990, timeout=timeout)
105+
ftp.login(user=ftp_user, passwd=ftp_pass)
106+
ftp.prot_p()
107+
return ftp
108+
except tuple(list(ftplib.all_errors) + [socket.timeout]) as e:
109+
if attempt < connect_retries:
110+
log(f"[WARNING] FTP connection failed ({e}). Retrying in 5s...")
111+
time.sleep(5)
112+
else:
113+
log(f"[ERROR] Could not establish FTP connection after {connect_retries} attempts.")
114+
try:
115+
ftp.close()
116+
except Exception:
117+
pass
118+
return None
119+
68120
def download3mfFromFTP(filename, destFile):
69121
log("Downloading 3MF file from FTP...")
70122
ftp_host = PRINTER_IP
71123
ftp_user = "bblp"
72124
ftp_pass = PRINTER_CODE
73-
local_path = destFile.name # 🔹 Download into the current directory
74-
base_name = os.path.basename(filename)
75-
remote_paths = [f"/cache/{base_name}", f"/{base_name}", f"/sdcard/{base_name}"]
125+
local_path = destFile.name
126+
base_name = os.path.basename(filename).lstrip("/")
127+
search_paths = ["/cache/", "/", "/sdcard/"]
128+
129+
filenames_to_try = []
130+
if base_name.endswith(".3mf"):
131+
filenames_to_try.append(base_name)
132+
else:
133+
filenames_to_try.append(f"{base_name}.3mf")
134+
filenames_to_try.append(f"{base_name}.gcode.3mf")
135+
if base_name and base_name not in filenames_to_try:
136+
filenames_to_try.insert(0, base_name)
76137

77138
max_retries = 6
78-
last_err_code = None
79-
path_count = len(remote_paths)
80-
# pycurl error codes we react to:
81-
# 7: could not connect, 28: timeout, 35: SSL connect error,
82-
# 52: empty reply, 55: send error, 56: recv error.
83-
# 78: remote file not found, 13: bad PASV/EPSV response, 9: access denied.
84-
reconnect_codes = {7, 28, 35, 52, 55, 56}
85-
c = setupPycurlConnection(ftp_user, ftp_pass)
86-
try:
87-
for attempt in range(1, max_retries + 1):
88-
path_index = (attempt - 1) % path_count
89-
remote_path = remote_paths[path_index]
90-
if not remote_path.startswith("/"):
91-
remote_path = "/" + remote_path
92-
encoded_remote_path = urllib.parse.quote(remote_path)
93-
url = f"ftps://{ftp_host}{encoded_remote_path}"
94-
95-
log(f"[DEBUG] Attempting file download ({path_index + 1}/{path_count}): {remote_path}") # Log attempted path
96-
139+
not_found_seen = False
140+
connect_retries = 3
141+
ftp = ensure_ftps_connection(ftp_host, ftp_user, ftp_pass, connect_retries)
142+
if ftp is None:
143+
return False
144+
145+
def attempt_single_download(path_prefix, name):
146+
remote_path = f"{path_prefix}{name.lstrip('/')}"
147+
log(f"[DEBUG] Attempting file download: {remote_path}")
148+
expected_size = None
149+
try:
150+
expected_size = int(ftp.size(remote_path))
151+
log(f"[DEBUG] Remote size for {remote_path}: {expected_size} bytes")
152+
except Exception as e:
153+
log(f"[WARNING] Could not fetch size for {remote_path}: {e}")
154+
try:
97155
with open(local_path, "wb") as f:
98-
try:
99-
c.setopt(c.URL, url)
100-
c.setopt(c.WRITEDATA, f)
101-
log(f"[DEBUG] Attempt {attempt}: Starting download of {remote_path}...")
102-
c.perform()
103-
log("[DEBUG] File successfully downloaded!")
104-
return True
105-
except pycurl.error as e:
106-
last_err_code = e.args[0]
107-
if last_err_code in reconnect_codes:
108-
log(f"[WARNING] FTP connection error (code {last_err_code}). Reconnecting...")
156+
ftp.retrbinary(f"RETR {remote_path}", f.write)
157+
except Exception:
158+
try:
159+
if os.path.exists(local_path):
160+
os.remove(local_path)
161+
except Exception:
162+
pass
163+
raise
164+
if expected_size is not None:
165+
try:
166+
local_size = os.path.getsize(local_path)
167+
if local_size != expected_size:
168+
log(f"[WARNING] Downloaded size mismatch for {remote_path}: local {local_size} vs remote {expected_size}")
169+
try:
170+
os.remove(local_path)
171+
except Exception:
172+
pass
173+
return False
174+
except Exception:
175+
pass
176+
return True
177+
178+
def find_latest_file(search_paths_inner, extensions):
179+
latest_path = None
180+
latest_time = None
181+
for path in search_paths_inner:
182+
try:
183+
entries = []
184+
def parse_line(line):
185+
pattern_with_time = r'^\\S+\\s+\\d+\\s+\\S+\\s+\\S+\\s+\\d+\\s+(\\S+\\s+\\d+\\s+\\d+:\\d+)\\s+(.+)$'
186+
pattern_with_year = r'^\\S+\\s+\\d+\\s+\\S+\\s+\\S+\\s+\\d+\\s+(\\S+\\s+\\d+\\s+\\d{4})\\s+(.+)$'
187+
m = re.match(pattern_with_time, line)
188+
ts = None
189+
fname = None
190+
if m:
191+
ts_str, fname = m.groups()
109192
try:
110-
c.close()
193+
ts = datetime.strptime(ts_str, "%b %d %H:%M").replace(year=datetime.now().year)
111194
except Exception:
112-
pass
113-
c = setupPycurlConnection(ftp_user, ftp_pass)
195+
ts = None
196+
else:
197+
m = re.match(pattern_with_year, line)
198+
if m:
199+
ts_str, fname = m.groups()
200+
try:
201+
ts = datetime.strptime(ts_str, "%b %d %Y")
202+
except Exception:
203+
ts = None
204+
if fname:
205+
_, ext = os.path.splitext(fname)
206+
if ext in extensions:
207+
entries.append((ts, f"{path}{fname}"))
208+
ftp.retrlines(f"LIST {path}", parse_line)
209+
for ts, pth in entries:
210+
if ts is None:
211+
continue
212+
if latest_time is None or ts > latest_time:
213+
latest_time = ts
214+
latest_path = pth
215+
except Exception:
216+
log(f"[ERROR] Could not LIST path {path}")
217+
return latest_path
114218

115-
if last_err_code in (78, 13):
116-
if attempt < max_retries:
117-
log(f"[WARNING] Transient FTP error (code {last_err_code}). Retrying in 5s...")
118-
time.sleep(5)
219+
try:
220+
attempt = 1
221+
while attempt <= max_retries:
222+
tried_any = False
223+
for candidate in filenames_to_try:
224+
for path_prefix in search_paths:
225+
try:
226+
tried_any = True
227+
log(f"[DEBUG] Attempt {attempt}: Starting download of {candidate} via {path_prefix}...")
228+
if attempt_single_download(path_prefix, candidate):
229+
log("[DEBUG] File successfully downloaded!")
230+
return True
231+
except ftplib.error_perm as e:
232+
message = str(e)
233+
lowered = message.lower()
234+
if message.startswith("550") or "denied" in lowered:
235+
not_found_seen = True
119236
continue
120-
log("[ERROR] Giving up after max retries for transient FTP errors.")
121-
break
122-
if last_err_code == 9:
123-
log("[DEBUG] Printer denied access to /cache path. Ensure external storage is setup to store print files in printer settings.")
237+
log(f"[ERROR] Fatal FTP permission error: {message}")
124238
return False
125-
log(f"[ERROR] Fatal cURL error {last_err_code}: {e}")
126-
return False
239+
except tuple(list(ftplib.all_errors) + [socket.timeout]) as e:
240+
log(f"[WARNING] FTP connection error ({e}). Reconnecting...")
241+
try:
242+
ftp.close()
243+
except Exception:
244+
pass
245+
ftp = ensure_ftps_connection(ftp_host, ftp_user, ftp_pass, connect_retries)
246+
if ftp is None:
247+
return False
248+
continue
249+
if not tried_any:
250+
break
251+
if attempt < max_retries:
252+
log(f"[WARNING] Transient FTP error. Retrying in 5s...")
253+
time.sleep(5)
254+
attempt += 1
255+
256+
latest = find_latest_file(search_paths, [".3mf"])
257+
if latest:
258+
log(f"[DEBUG] Falling back to latest .3mf: {latest}")
259+
expected_size = None
260+
try:
261+
expected_size = int(ftp.size(latest))
262+
log(f"[DEBUG] Remote size for fallback {latest}: {expected_size} bytes")
263+
except Exception as e:
264+
log(f"[WARNING] Could not fetch size for fallback {latest}: {e}")
265+
try:
266+
with open(local_path, "wb") as f:
267+
ftp.retrbinary(f"RETR {latest}", f.write)
268+
if expected_size is not None:
269+
try:
270+
local_size = os.path.getsize(local_path)
271+
if local_size != expected_size:
272+
log(f"[WARNING] Downloaded size mismatch for fallback {latest}: local {local_size} vs remote {expected_size}")
273+
try:
274+
os.remove(local_path)
275+
except Exception:
276+
pass
277+
return False
278+
except Exception:
279+
pass
280+
log("[DEBUG] File successfully downloaded via fallback.")
281+
return True
282+
except Exception as e:
283+
try:
284+
if os.path.exists(local_path):
285+
os.remove(local_path)
286+
except Exception:
287+
pass
288+
log(f"[ERROR] Fallback download failed for {latest}: {e}")
127289
finally:
128-
if c is not None:
290+
if ftp is not None:
129291
try:
130-
c.close()
292+
ftp.close()
131293
except Exception:
132294
pass
133295

134-
if last_err_code == 78:
296+
if not_found_seen:
135297
log("[ERROR] File not found after max retries.")
136-
list_conn = setupPycurlConnection(ftp_user, ftp_pass)
137-
try:
138-
for list_path in ("/", "/sdcard/", "/cache/"):
298+
list_conn = ensure_ftps_connection(ftp_host, ftp_user, ftp_pass, connect_retries)
299+
if list_conn:
300+
try:
301+
list_path = "/"
139302
log(f"[DEBUG] Listing found printer files in {list_path} directory")
140-
buffer = io.BytesIO()
141-
list_conn.setopt(list_conn.URL, f"ftps://{ftp_host}{list_path}")
142-
list_conn.setopt(list_conn.WRITEDATA, buffer)
143-
list_conn.setopt(list_conn.DIRLISTONLY, True)
144303
try:
145-
list_conn.perform()
146-
log(f"[DEBUG] Directory Listing ({list_path}): {buffer.getvalue().decode('utf-8').splitlines()}")
304+
listing = list_conn.nlst(list_path)
305+
log(f"[DEBUG] Directory Listing ({list_path}): {listing}")
147306
except Exception:
148307
log(f"[ERROR] Could not retrieve directory listing for {list_path}.")
149-
finally:
150-
try:
151-
list_conn.close()
152-
except Exception:
153-
pass
308+
finally:
309+
try:
310+
list_conn.close()
311+
except Exception:
312+
pass
154313
return False
155314

156-
def setupPycurlConnection(ftp_user, ftp_pass):
157-
# Setup shared options for curl connections
158-
c = pycurl.Curl()
159-
160-
# 🔹 Setup explicit FTPS connection (like FileZilla)
161-
162-
c.setopt(c.USERPWD, f"{ftp_user}:{ftp_pass}")
163-
164-
165-
# 🔹 Enable SSL/TLS
166-
c.setopt(c.SSL_VERIFYPEER, 0) # Disable SSL verification
167-
c.setopt(c.SSL_VERIFYHOST, 0)
168-
169-
# 🔹 Enable passive mode (like FileZilla)
170-
c.setopt(c.FTP_SSL, c.FTPSSL_ALL)
171-
172-
# 🔹 Enable proper TLS authentication
173-
c.setopt(c.FTPSSLAUTH, c.FTPAUTH_TLS)
174-
175-
return c
176-
177315
def download3mfFromLocalFilesystem(path, destFile):
178316
with open(path, "rb") as src_file:
179317
destFile.write(src_file.read())

0 commit comments

Comments
 (0)