1use std::collections::HashMap;
7use std::path::Path;
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11use crate::config::ResolvedTarget;
12use crate::ftp::{FileEntry, FtpsClient};
13
14const LIST_TTL: Duration = Duration::from_secs(6);
17
18pub trait FileStore: Send + Sync {
21 fn list(&self, dir: &str) -> Result<Vec<FileEntry>, String>;
23 fn upload(&self, remote_path: &str, local: &Path) -> Result<(), String>;
25 fn thumbnail(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String>;
28 fn fetch(&self, remote_path: &str) -> Result<Vec<u8>, String>;
30 fn gcode(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String>;
33 fn models(&self, remote_path: &str) -> Result<Vec<String>, String>;
38}
39
40const MODELS_MAX: usize = 48 * 1024 * 1024;
42
43fn extract_models(zip_bytes: &[u8]) -> Result<Vec<String>, String> {
47 use std::io::Read;
48 let mut archive =
49 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
50 let mut out = Vec::new();
51 let mut total = 0usize;
52 for i in 0..archive.len() {
53 let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
54 let name = entry.name().to_string();
55 if !(name.starts_with("3D/") && name.ends_with(".model")) {
56 continue;
57 }
58 let mut buf = String::new();
59 if entry.read_to_string(&mut buf).is_err() {
60 continue; }
62 if !buf.contains("<mesh") {
63 continue; }
65 total += buf.len();
66 if total > MODELS_MAX {
67 break;
68 }
69 out.push(buf);
70 }
71 Ok(out)
72}
73
74fn extract_gcode(zip_bytes: &[u8], plate: u32) -> Result<Option<Vec<u8>>, String> {
76 use std::io::Read;
77 let mut archive =
78 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
79 let mut entry = match archive.by_name(&format!("Metadata/plate_{plate}.gcode")) {
80 Ok(e) => e,
81 Err(_) => return Ok(None),
82 };
83 let mut buf = Vec::new();
84 entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
85 Ok((!buf.is_empty()).then_some(buf))
86}
87
88pub const RAW_MAX: u64 = 64 * 1024 * 1024;
90
91fn extract_thumbnail(zip_bytes: &[u8], plate: u32) -> Result<Option<Vec<u8>>, String> {
94 use std::io::Read;
95 let mut archive =
96 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
97 for name in [
98 format!("Metadata/plate_{plate}.png"),
99 format!("Metadata/plate_{plate}_small.png"),
100 ] {
101 if let Ok(mut entry) = archive.by_name(&name) {
102 let mut buf = Vec::new();
103 entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
104 if !buf.is_empty() {
105 return Ok(Some(buf));
106 }
107 }
108 }
109 Ok(None)
110}
111
112pub struct LiveFiles {
114 target: ResolvedTarget,
115 thumb_cache: Mutex<HashMap<String, Option<Vec<u8>>>>,
119 list_cache: Mutex<HashMap<String, (Instant, Vec<FileEntry>)>>,
122 gcode_cache: Mutex<HashMap<String, Option<Vec<u8>>>>,
126 models_cache: Mutex<HashMap<String, Vec<String>>>,
129}
130
131const GCODE_CACHE_MAX: usize = 8 * 1024 * 1024;
134
135impl LiveFiles {
136 pub fn new(target: ResolvedTarget) -> Self {
137 Self {
138 target,
139 thumb_cache: Mutex::new(HashMap::new()),
140 list_cache: Mutex::new(HashMap::new()),
141 gcode_cache: Mutex::new(HashMap::new()),
142 models_cache: Mutex::new(HashMap::new()),
143 }
144 }
145}
146
147impl FileStore for LiveFiles {
148 fn list(&self, dir: &str) -> Result<Vec<FileEntry>, String> {
149 if let Some((at, entries)) = self
150 .list_cache
151 .lock()
152 .unwrap_or_else(|e| e.into_inner())
153 .get(dir)
154 && at.elapsed() < LIST_TTL
155 {
156 return Ok(entries.clone());
157 }
158 let entries = FtpsClient::new(self.target.clone())
159 .list_entries(dir)
160 .map_err(|e| e.to_string())?;
161 let mut cache = self.list_cache.lock().unwrap_or_else(|e| e.into_inner());
162 if cache.len() >= 64 {
163 cache.clear();
164 }
165 cache.insert(dir.to_string(), (Instant::now(), entries.clone()));
166 Ok(entries)
167 }
168
169 fn upload(&self, remote_path: &str, local: &Path) -> Result<(), String> {
170 FtpsClient::new(self.target.clone())
171 .upload(local, remote_path)
172 .map(|_| ())
173 .map_err(|e| e.to_string())?;
174 self.list_cache
176 .lock()
177 .unwrap_or_else(|e| e.into_inner())
178 .clear();
179 Ok(())
180 }
181
182 fn thumbnail(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String> {
183 let key = format!("{remote_path}#{plate}");
184 if let Some(hit) = self
186 .thumb_cache
187 .lock()
188 .unwrap_or_else(|e| e.into_inner())
189 .get(&key)
190 {
191 return Ok(hit.clone());
192 }
193 let tmp = tempfile::Builder::new()
194 .prefix("bambu-thumb-")
195 .tempfile()
196 .map_err(|e| e.to_string())?;
197 FtpsClient::new(self.target.clone())
198 .download(remote_path, tmp.path())
199 .map_err(|e| e.to_string())?;
200 let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
201 let thumb = extract_thumbnail(&bytes, plate)?;
202 let mut cache = self.thumb_cache.lock().unwrap_or_else(|e| e.into_inner());
203 if cache.len() >= 128 {
205 cache.clear();
206 }
207 cache.insert(key, thumb.clone());
208 Ok(thumb)
209 }
210
211 fn fetch(&self, remote_path: &str) -> Result<Vec<u8>, String> {
212 let tmp = tempfile::Builder::new()
213 .prefix("bambu-raw-")
214 .tempfile()
215 .map_err(|e| e.to_string())?;
216 FtpsClient::new(self.target.clone())
217 .download(remote_path, tmp.path())
218 .map_err(|e| e.to_string())?;
219 let meta = std::fs::metadata(tmp.path()).map_err(|e| e.to_string())?;
220 if meta.len() > RAW_MAX {
221 return Err(format!("file too large to view ({} bytes)", meta.len()));
222 }
223 std::fs::read(tmp.path()).map_err(|e| e.to_string())
224 }
225
226 fn gcode(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String> {
227 let key = format!("{remote_path}#{plate}");
228 if let Some(hit) = self
229 .gcode_cache
230 .lock()
231 .unwrap_or_else(|e| e.into_inner())
232 .get(&key)
233 {
234 return Ok(hit.clone());
235 }
236 let tmp = tempfile::Builder::new()
237 .prefix("bambu-gcode-")
238 .tempfile()
239 .map_err(|e| e.to_string())?;
240 FtpsClient::new(self.target.clone())
241 .download(remote_path, tmp.path())
242 .map_err(|e| e.to_string())?;
243 let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
244 let gcode = extract_gcode(&bytes, plate)?;
245 if gcode.as_ref().is_none_or(|g| g.len() <= GCODE_CACHE_MAX) {
248 let mut cache = self.gcode_cache.lock().unwrap_or_else(|e| e.into_inner());
249 if cache.len() >= 16 {
250 cache.clear();
251 }
252 cache.insert(key, gcode.clone());
253 }
254 Ok(gcode)
255 }
256
257 fn models(&self, remote_path: &str) -> Result<Vec<String>, String> {
258 if let Some(hit) = self
259 .models_cache
260 .lock()
261 .unwrap_or_else(|e| e.into_inner())
262 .get(remote_path)
263 {
264 return Ok(hit.clone());
265 }
266 let tmp = tempfile::Builder::new()
267 .prefix("bambu-mesh-")
268 .tempfile()
269 .map_err(|e| e.to_string())?;
270 FtpsClient::new(self.target.clone())
271 .download(remote_path, tmp.path())
272 .map_err(|e| e.to_string())?;
273 let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
274 let models = extract_models(&bytes)?;
275 let total: usize = models.iter().map(String::len).sum();
277 if total <= GCODE_CACHE_MAX {
278 let mut cache = self.models_cache.lock().unwrap_or_else(|e| e.into_inner());
279 if cache.len() >= 16 {
280 cache.clear();
281 }
282 cache.insert(remote_path.to_string(), models.clone());
283 }
284 Ok(models)
285 }
286}
287
288pub struct FakeFiles;
290
291impl FileStore for FakeFiles {
292 fn list(&self, _dir: &str) -> Result<Vec<FileEntry>, String> {
293 let entry = |name: &str, is_dir: bool, size: u64| FileEntry {
294 name: name.to_string(),
295 is_dir,
296 size,
297 };
298 Ok(vec![
299 entry("cache", true, 0),
300 entry("timelapse", true, 0),
301 entry("coin2c.gcode.3mf", false, 184_320),
302 entry("benchy_2c.3mf", false, 256_000),
303 ])
304 }
305 fn upload(&self, _remote_path: &str, _local: &Path) -> Result<(), String> {
306 Ok(())
307 }
308 fn thumbnail(&self, _remote_path: &str, _plate: u32) -> Result<Option<Vec<u8>>, String> {
309 Ok(Some(TINY_PNG.to_vec()))
311 }
312 fn fetch(&self, _remote_path: &str) -> Result<Vec<u8>, String> {
313 Ok(b"fake-model".to_vec())
315 }
316 fn gcode(&self, _remote_path: &str, _plate: u32) -> Result<Option<Vec<u8>>, String> {
317 Ok(Some(fake_gcode().into_bytes()))
320 }
321 fn models(&self, _remote_path: &str) -> Result<Vec<String>, String> {
322 Ok(vec![FAKE_CUBE_MODEL.to_string()])
325 }
326}
327
328const FAKE_CUBE_MODEL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
331<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
332 <resources><object id="1" type="model"><mesh>
333 <vertices>
334 <vertex x="0" y="0" z="0"/><vertex x="10" y="0" z="0"/><vertex x="10" y="10" z="0"/><vertex x="0" y="10" z="0"/>
335 <vertex x="0" y="0" z="10"/><vertex x="10" y="0" z="10"/><vertex x="10" y="10" z="10"/><vertex x="0" y="10" z="10"/>
336 </vertices>
337 <triangles>
338 <triangle v1="0" v2="2" v3="1"/><triangle v1="0" v2="3" v3="2"/>
339 <triangle v1="4" v2="5" v3="6"/><triangle v1="4" v2="6" v3="7"/>
340 <triangle v1="0" v2="1" v3="5"/><triangle v1="0" v2="5" v3="4"/>
341 <triangle v1="1" v2="2" v3="6"/><triangle v1="1" v2="6" v3="5"/>
342 <triangle v1="2" v2="3" v3="7"/><triangle v1="2" v2="7" v3="6"/>
343 <triangle v1="3" v2="0" v3="4"/><triangle v1="3" v2="4" v3="7"/>
344 </triangles>
345 </mesh></object></resources>
346</model>"#;
347
348fn fake_gcode() -> String {
351 let mut s = String::from("; fake sample toolpath\nG21\nG90\nM82\n");
352 let mut e = 0.0_f64;
353 for layer in 0..6 {
354 let z = 0.2 * (layer as f64 + 1.0);
355 s.push_str(&format!("G1 Z{z:.2} F600\nG1 X10 Y10 F3000\n"));
356 for &(x, y) in &[(30.0, 10.0), (30.0, 30.0), (10.0, 30.0), (10.0, 10.0)] {
357 e += 1.0;
358 s.push_str(&format!("G1 X{x:.1} Y{y:.1} E{e:.3} F1200\n"));
359 }
360 }
361 s
362}
363
364const TINY_PNG: &[u8] = &[
366 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
367 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
368 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
369 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
370 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
371];
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn extracts_plate_thumbnail_from_a_3mf_zip() {
379 use std::io::Write;
381 let mut buf = Vec::new();
382 {
383 let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
384 zw.start_file::<_, ()>("Metadata/plate_1.png", zip::write::FileOptions::default())
385 .unwrap();
386 zw.write_all(TINY_PNG).unwrap();
387 zw.finish().unwrap();
388 }
389 assert_eq!(
390 extract_thumbnail(&buf, 1).unwrap().as_deref(),
391 Some(TINY_PNG)
392 );
393 assert_eq!(extract_thumbnail(&buf, 2).unwrap(), None); }
395
396 #[test]
397 fn extracts_plate_gcode_from_a_3mf_zip() {
398 use std::io::Write;
399 let mut buf = Vec::new();
400 {
401 let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
402 zw.start_file::<_, ()>("Metadata/plate_1.gcode", zip::write::FileOptions::default())
403 .unwrap();
404 zw.write_all(b"G1 X0 Y0\nG1 X10 Y10 E1\n").unwrap();
405 zw.finish().unwrap();
406 }
407 assert!(
408 extract_gcode(&buf, 1)
409 .unwrap()
410 .unwrap()
411 .starts_with(b"G1 X0 Y0")
412 );
413 assert_eq!(extract_gcode(&buf, 2).unwrap(), None); }
415
416 #[test]
417 fn fake_gcode_is_parseable_extruding_toolpath() {
418 let g = String::from_utf8(fake_gcode().into_bytes()).unwrap();
419 assert!(g.contains("G1 X30.0 Y10.0 E"));
420 }
421
422 #[test]
423 fn extracts_only_mesh_bearing_models_from_a_3mf_zip() {
424 use std::io::Write;
425 let mut buf = Vec::new();
426 {
427 let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
428 zw.start_file::<_, ()>("3D/3dmodel.model", zip::write::FileOptions::default())
430 .unwrap();
431 zw.write_all(
432 b"<model><resources><object id=\"2\"><components/></object></resources></model>",
433 )
434 .unwrap();
435 zw.start_file::<_, ()>(
437 "3D/Objects/part_1.model",
438 zip::write::FileOptions::default(),
439 )
440 .unwrap();
441 zw.write_all(b"<model><resources><object id=\"1\"><mesh><vertices/></mesh></object></resources></model>")
442 .unwrap();
443 zw.finish().unwrap();
444 }
445 let models = extract_models(&buf).unwrap();
446 assert_eq!(models.len(), 1);
447 assert!(models[0].contains("<mesh"));
448 }
449
450 #[test]
451 fn fake_cube_model_is_a_valid_mesh() {
452 let models = FakeFiles.models("/x.3mf").unwrap();
453 assert_eq!(models.len(), 1);
454 assert!(models[0].contains("<vertex "));
455 assert!(models[0].contains("<triangle "));
456 }
457}