use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::config::ResolvedTarget;
use crate::ftp::{FileEntry, FtpsClient};
const LIST_TTL: Duration = Duration::from_secs(6);
pub trait FileStore: Send + Sync {
fn list(&self, dir: &str) -> Result<Vec<FileEntry>, String>;
fn upload(&self, remote_path: &str, local: &Path) -> Result<(), String>;
fn thumbnail(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String>;
fn fetch(&self, remote_path: &str) -> Result<Vec<u8>, String>;
fn gcode(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String>;
fn models(&self, remote_path: &str) -> Result<Vec<String>, String>;
}
const MODELS_MAX: usize = 48 * 1024 * 1024;
fn extract_models(zip_bytes: &[u8]) -> Result<Vec<String>, String> {
use std::io::Read;
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
let mut out = Vec::new();
let mut total = 0usize;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
let name = entry.name().to_string();
if !(name.starts_with("3D/") && name.ends_with(".model")) {
continue;
}
let mut buf = String::new();
if entry.read_to_string(&mut buf).is_err() {
continue; }
if !buf.contains("<mesh") {
continue; }
total += buf.len();
if total > MODELS_MAX {
break;
}
out.push(buf);
}
Ok(out)
}
fn extract_gcode(zip_bytes: &[u8], plate: u32) -> Result<Option<Vec<u8>>, String> {
use std::io::Read;
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
let mut entry = match archive.by_name(&format!("Metadata/plate_{plate}.gcode")) {
Ok(e) => e,
Err(_) => return Ok(None),
};
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
Ok((!buf.is_empty()).then_some(buf))
}
pub const RAW_MAX: u64 = 64 * 1024 * 1024;
fn extract_thumbnail(zip_bytes: &[u8], plate: u32) -> Result<Option<Vec<u8>>, String> {
use std::io::Read;
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
for name in [
format!("Metadata/plate_{plate}.png"),
format!("Metadata/plate_{plate}_small.png"),
] {
if let Ok(mut entry) = archive.by_name(&name) {
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
if !buf.is_empty() {
return Ok(Some(buf));
}
}
}
Ok(None)
}
pub struct LiveFiles {
target: ResolvedTarget,
thumb_cache: Mutex<HashMap<String, Option<Vec<u8>>>>,
list_cache: Mutex<HashMap<String, (Instant, Vec<FileEntry>)>>,
gcode_cache: Mutex<HashMap<String, Option<Vec<u8>>>>,
models_cache: Mutex<HashMap<String, Vec<String>>>,
}
const GCODE_CACHE_MAX: usize = 8 * 1024 * 1024;
impl LiveFiles {
pub fn new(target: ResolvedTarget) -> Self {
Self {
target,
thumb_cache: Mutex::new(HashMap::new()),
list_cache: Mutex::new(HashMap::new()),
gcode_cache: Mutex::new(HashMap::new()),
models_cache: Mutex::new(HashMap::new()),
}
}
}
impl FileStore for LiveFiles {
fn list(&self, dir: &str) -> Result<Vec<FileEntry>, String> {
if let Some((at, entries)) = self
.list_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(dir)
&& at.elapsed() < LIST_TTL
{
return Ok(entries.clone());
}
let entries = FtpsClient::new(self.target.clone())
.list_entries(dir)
.map_err(|e| e.to_string())?;
let mut cache = self.list_cache.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= 64 {
cache.clear();
}
cache.insert(dir.to_string(), (Instant::now(), entries.clone()));
Ok(entries)
}
fn upload(&self, remote_path: &str, local: &Path) -> Result<(), String> {
FtpsClient::new(self.target.clone())
.upload(local, remote_path)
.map(|_| ())
.map_err(|e| e.to_string())?;
self.list_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
Ok(())
}
fn thumbnail(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String> {
let key = format!("{remote_path}#{plate}");
if let Some(hit) = self
.thumb_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
{
return Ok(hit.clone());
}
let tmp = tempfile::Builder::new()
.prefix("bambu-thumb-")
.tempfile()
.map_err(|e| e.to_string())?;
FtpsClient::new(self.target.clone())
.download(remote_path, tmp.path())
.map_err(|e| e.to_string())?;
let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
let thumb = extract_thumbnail(&bytes, plate)?;
let mut cache = self.thumb_cache.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= 128 {
cache.clear();
}
cache.insert(key, thumb.clone());
Ok(thumb)
}
fn fetch(&self, remote_path: &str) -> Result<Vec<u8>, String> {
let tmp = tempfile::Builder::new()
.prefix("bambu-raw-")
.tempfile()
.map_err(|e| e.to_string())?;
FtpsClient::new(self.target.clone())
.download(remote_path, tmp.path())
.map_err(|e| e.to_string())?;
let meta = std::fs::metadata(tmp.path()).map_err(|e| e.to_string())?;
if meta.len() > RAW_MAX {
return Err(format!("file too large to view ({} bytes)", meta.len()));
}
std::fs::read(tmp.path()).map_err(|e| e.to_string())
}
fn gcode(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String> {
let key = format!("{remote_path}#{plate}");
if let Some(hit) = self
.gcode_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
{
return Ok(hit.clone());
}
let tmp = tempfile::Builder::new()
.prefix("bambu-gcode-")
.tempfile()
.map_err(|e| e.to_string())?;
FtpsClient::new(self.target.clone())
.download(remote_path, tmp.path())
.map_err(|e| e.to_string())?;
let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
let gcode = extract_gcode(&bytes, plate)?;
if gcode.as_ref().is_none_or(|g| g.len() <= GCODE_CACHE_MAX) {
let mut cache = self.gcode_cache.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= 16 {
cache.clear();
}
cache.insert(key, gcode.clone());
}
Ok(gcode)
}
fn models(&self, remote_path: &str) -> Result<Vec<String>, String> {
if let Some(hit) = self
.models_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(remote_path)
{
return Ok(hit.clone());
}
let tmp = tempfile::Builder::new()
.prefix("bambu-mesh-")
.tempfile()
.map_err(|e| e.to_string())?;
FtpsClient::new(self.target.clone())
.download(remote_path, tmp.path())
.map_err(|e| e.to_string())?;
let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
let models = extract_models(&bytes)?;
let total: usize = models.iter().map(String::len).sum();
if total <= GCODE_CACHE_MAX {
let mut cache = self.models_cache.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= 16 {
cache.clear();
}
cache.insert(remote_path.to_string(), models.clone());
}
Ok(models)
}
}
pub struct FakeFiles;
impl FileStore for FakeFiles {
fn list(&self, _dir: &str) -> Result<Vec<FileEntry>, String> {
let entry = |name: &str, is_dir: bool, size: u64| FileEntry {
name: name.to_string(),
is_dir,
size,
};
Ok(vec![
entry("cache", true, 0),
entry("timelapse", true, 0),
entry("coin2c.gcode.3mf", false, 184_320),
entry("benchy_2c.3mf", false, 256_000),
])
}
fn upload(&self, _remote_path: &str, _local: &Path) -> Result<(), String> {
Ok(())
}
fn thumbnail(&self, _remote_path: &str, _plate: u32) -> Result<Option<Vec<u8>>, String> {
Ok(Some(TINY_PNG.to_vec()))
}
fn fetch(&self, _remote_path: &str) -> Result<Vec<u8>, String> {
Ok(b"fake-model".to_vec())
}
fn gcode(&self, _remote_path: &str, _plate: u32) -> Result<Option<Vec<u8>>, String> {
Ok(Some(fake_gcode().into_bytes()))
}
fn models(&self, _remote_path: &str) -> Result<Vec<String>, String> {
Ok(vec![FAKE_CUBE_MODEL.to_string()])
}
}
const FAKE_CUBE_MODEL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
<resources><object id="1" type="model"><mesh>
<vertices>
<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"/>
<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"/>
</vertices>
<triangles>
<triangle v1="0" v2="2" v3="1"/><triangle v1="0" v2="3" v3="2"/>
<triangle v1="4" v2="5" v3="6"/><triangle v1="4" v2="6" v3="7"/>
<triangle v1="0" v2="1" v3="5"/><triangle v1="0" v2="5" v3="4"/>
<triangle v1="1" v2="2" v3="6"/><triangle v1="1" v2="6" v3="5"/>
<triangle v1="2" v2="3" v3="7"/><triangle v1="2" v2="7" v3="6"/>
<triangle v1="3" v2="0" v3="4"/><triangle v1="3" v2="4" v3="7"/>
</triangles>
</mesh></object></resources>
</model>"#;
fn fake_gcode() -> String {
let mut s = String::from("; fake sample toolpath\nG21\nG90\nM82\n");
let mut e = 0.0_f64;
for layer in 0..6 {
let z = 0.2 * (layer as f64 + 1.0);
s.push_str(&format!("G1 Z{z:.2} F600\nG1 X10 Y10 F3000\n"));
for &(x, y) in &[(30.0, 10.0), (30.0, 30.0), (10.0, 30.0), (10.0, 10.0)] {
e += 1.0;
s.push_str(&format!("G1 X{x:.1} Y{y:.1} E{e:.3} F1200\n"));
}
}
s
}
const TINY_PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_plate_thumbnail_from_a_3mf_zip() {
use std::io::Write;
let mut buf = Vec::new();
{
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
zw.start_file::<_, ()>("Metadata/plate_1.png", zip::write::FileOptions::default())
.unwrap();
zw.write_all(TINY_PNG).unwrap();
zw.finish().unwrap();
}
assert_eq!(
extract_thumbnail(&buf, 1).unwrap().as_deref(),
Some(TINY_PNG)
);
assert_eq!(extract_thumbnail(&buf, 2).unwrap(), None); }
#[test]
fn extracts_plate_gcode_from_a_3mf_zip() {
use std::io::Write;
let mut buf = Vec::new();
{
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
zw.start_file::<_, ()>("Metadata/plate_1.gcode", zip::write::FileOptions::default())
.unwrap();
zw.write_all(b"G1 X0 Y0\nG1 X10 Y10 E1\n").unwrap();
zw.finish().unwrap();
}
assert!(
extract_gcode(&buf, 1)
.unwrap()
.unwrap()
.starts_with(b"G1 X0 Y0")
);
assert_eq!(extract_gcode(&buf, 2).unwrap(), None); }
#[test]
fn fake_gcode_is_parseable_extruding_toolpath() {
let g = String::from_utf8(fake_gcode().into_bytes()).unwrap();
assert!(g.contains("G1 X30.0 Y10.0 E"));
}
#[test]
fn extracts_only_mesh_bearing_models_from_a_3mf_zip() {
use std::io::Write;
let mut buf = Vec::new();
{
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
zw.start_file::<_, ()>("3D/3dmodel.model", zip::write::FileOptions::default())
.unwrap();
zw.write_all(
b"<model><resources><object id=\"2\"><components/></object></resources></model>",
)
.unwrap();
zw.start_file::<_, ()>(
"3D/Objects/part_1.model",
zip::write::FileOptions::default(),
)
.unwrap();
zw.write_all(b"<model><resources><object id=\"1\"><mesh><vertices/></mesh></object></resources></model>")
.unwrap();
zw.finish().unwrap();
}
let models = extract_models(&buf).unwrap();
assert_eq!(models.len(), 1);
assert!(models[0].contains("<mesh"));
}
#[test]
fn fake_cube_model_is_a_valid_mesh() {
let models = FakeFiles.models("/x.3mf").unwrap();
assert_eq!(models.len(), 1);
assert!(models[0].contains("<vertex "));
assert!(models[0].contains("<triangle "));
}
}