test
This commit is contained in:
181
src/main.rs
181
src/main.rs
@ -22,12 +22,13 @@ use tracing::info;
|
|||||||
|
|
||||||
async fn get_version() -> impl IntoResponse {
|
async fn get_version() -> impl IntoResponse {
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"timestamp": chrono::Utc::now().timestamp()
|
"timestamp": chrono::Utc::now().timestamp()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────── Модели ───────────────────────────────
|
// ─────────────────────────────── Модели ───────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
struct PrintSettings {
|
struct PrintSettings {
|
||||||
filename: String,
|
filename: String,
|
||||||
@ -48,8 +49,9 @@ enum FileStatus {
|
|||||||
Queued,
|
Queued,
|
||||||
Printing,
|
Printing,
|
||||||
Sent,
|
Sent,
|
||||||
//AwaitingClearOutput,
|
AwaitingClearOutput,
|
||||||
AwaitingFlip,
|
AwaitingFlip,
|
||||||
|
AwaitingPickup,
|
||||||
Done,
|
Done,
|
||||||
Error,
|
Error,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
@ -98,6 +100,8 @@ struct Job {
|
|||||||
current: Option<usize>,
|
current: Option<usize>,
|
||||||
phase: JobPhase,
|
phase: JobPhase,
|
||||||
cups_id: Option<String>,
|
cups_id: Option<String>,
|
||||||
|
/// true если только что завершился ПЕРВЫЙ проход дуплекса
|
||||||
|
duplex_first_pass_done: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Job {
|
impl Job {
|
||||||
@ -116,6 +120,14 @@ impl Job {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_current_duplex(&self) -> bool {
|
||||||
|
self.current
|
||||||
|
.and_then(|i| self.files.get(i))
|
||||||
|
.and_then(|f| f.settings.as_ref())
|
||||||
|
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn should_remove_top_sheet(settings: &Option<PrintSettings>) -> bool {
|
fn should_remove_top_sheet(settings: &Option<PrintSettings>) -> bool {
|
||||||
let Some(s) = settings else { return false };
|
let Some(s) = settings else { return false };
|
||||||
if s.sides.as_deref() != Some("Двусторонняя") { return false; }
|
if s.sides.as_deref() != Some("Двусторонняя") { return false; }
|
||||||
@ -142,6 +154,7 @@ type JobStore = Arc<Mutex<HashMap<String, Job>>>;
|
|||||||
static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
|
static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
// ─────────────────────────────── main ───────────────────────────────
|
// ─────────────────────────────── main ───────────────────────────────
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
@ -172,11 +185,13 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────── Обработчики ───────────────────────────
|
// ─────────────────────────── Обработчики ───────────────────────────
|
||||||
|
|
||||||
async fn create_print(
|
async fn create_print(
|
||||||
State(store): State<JobStore>,
|
State(store): State<JobStore>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<JobView>, (StatusCode, String)> {
|
) -> Result<Json<JobView>, (StatusCode, String)> {
|
||||||
info!("📥 Получен запрос на печать");
|
info!("📥 Получен запрос на печать");
|
||||||
|
|
||||||
let mut settings_map: HashMap<String, PrintSettings> = HashMap::new();
|
let mut settings_map: HashMap<String, PrintSettings> = HashMap::new();
|
||||||
let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new();
|
let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new();
|
||||||
|
|
||||||
@ -202,13 +217,20 @@ async fn create_print(
|
|||||||
|
|
||||||
let mut job_files: Vec<JobFile> = Vec::new();
|
let mut job_files: Vec<JobFile> = Vec::new();
|
||||||
for (file_name, data) in files_data {
|
for (file_name, data) in files_data {
|
||||||
let ext = Path::new(&file_name).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
|
let ext = Path::new(&file_name)
|
||||||
let temp = if ext.is_empty() { NamedTempFile::new() } else { NamedTempFile::with_suffix(&format!(".{}", ext)) };
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
let temp = if ext.is_empty() {
|
||||||
|
NamedTempFile::new()
|
||||||
|
} else {
|
||||||
|
NamedTempFile::with_suffix(&format!(".{}", ext))
|
||||||
|
};
|
||||||
if let Ok(temp) = temp {
|
if let Ok(temp) = temp {
|
||||||
let path = temp.into_temp_path();
|
let path = temp.into_temp_path();
|
||||||
if fs::write(&*path, &data).await.is_ok() {
|
if fs::write(&*path, &data).await.is_ok() {
|
||||||
let settings = settings_map.get(&file_name).cloned();
|
let settings = settings_map.get(&file_name).cloned();
|
||||||
|
|
||||||
job_files.push(JobFile {
|
job_files.push(JobFile {
|
||||||
name: file_name,
|
name: file_name,
|
||||||
path,
|
path,
|
||||||
@ -231,7 +253,9 @@ async fn create_print(
|
|||||||
current: None,
|
current: None,
|
||||||
phase: JobPhase::WaitingStart,
|
phase: JobPhase::WaitingStart,
|
||||||
cups_id: None,
|
cups_id: None,
|
||||||
|
duplex_first_pass_done: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let view = job.view();
|
let view = job.view();
|
||||||
store.lock().unwrap().insert(job_id.clone(), job);
|
store.lock().unwrap().insert(job_id.clone(), job);
|
||||||
Ok(Json(view))
|
Ok(Json(view))
|
||||||
@ -257,20 +281,39 @@ async fn job_advance(
|
|||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?;
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Задание не найдено".into()))?;
|
||||||
|
|
||||||
let si = match job.phase {
|
let si = match job.phase {
|
||||||
|
// ── Старт: первый файл ──
|
||||||
JobPhase::WaitingStart => {
|
JobPhase::WaitingStart => {
|
||||||
let next_idx = 0;
|
let next_idx = 0;
|
||||||
job.current = Some(next_idx);
|
job.current = Some(next_idx);
|
||||||
if is_duplex_and_needs_clear(job, next_idx) {
|
Some(start_printing_locked(job, next_idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Пользователь подтвердил «забрал / печать закончена» ──
|
||||||
|
JobPhase::AwaitingPickup => {
|
||||||
|
let c = job.current.unwrap_or(0);
|
||||||
|
|
||||||
|
if job.duplex_first_pass_done {
|
||||||
|
// Первый проход дуплекса завершён → просим убрать листы
|
||||||
|
job.duplex_first_pass_done = false;
|
||||||
|
job.files[c].status = FileStatus::AwaitingClearOutput;
|
||||||
job.phase = JobPhase::AwaitingClearOutput;
|
job.phase = JobPhase::AwaitingClearOutput;
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(start_printing_locked(job, next_idx))
|
// Обычная печать или второй проход дуплекса → файл готов
|
||||||
|
job.files[c].status = FileStatus::Done;
|
||||||
|
begin_next_locked(job, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Пользователь убрал листы из выходного лотка ──
|
||||||
JobPhase::AwaitingClearOutput => {
|
JobPhase::AwaitingClearOutput => {
|
||||||
let c = job.current.unwrap_or(0);
|
let c = job.current.unwrap_or(0);
|
||||||
Some(start_printing_locked(job, c))
|
job.files[c].status = FileStatus::AwaitingFlip;
|
||||||
|
job.phase = JobPhase::AwaitingFlip;
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Пользователь переложил бумагу → запускаем второй проход ──
|
||||||
JobPhase::AwaitingFlip => {
|
JobPhase::AwaitingFlip => {
|
||||||
let c = job.current.unwrap();
|
let c = job.current.unwrap();
|
||||||
job.files[c].status = FileStatus::Printing;
|
job.files[c].status = FileStatus::Printing;
|
||||||
@ -283,15 +326,10 @@ async fn job_advance(
|
|||||||
is_second_pass: true,
|
is_second_pass: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
JobPhase::AwaitingPickup => match job.current.take() {
|
|
||||||
Some(c) => {
|
|
||||||
job.files[c].status = FileStatus::Done;
|
|
||||||
begin_next_locked(job, c)
|
|
||||||
}
|
|
||||||
None => { job.phase = JobPhase::Finished; None }
|
|
||||||
},
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
(job.view(), si)
|
(job.view(), si)
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -337,11 +375,13 @@ async fn job_cancel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────── Печать: state machine ───────────────────────
|
// ─────────────────────── Печать: state machine ───────────────────────
|
||||||
|
|
||||||
fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo {
|
fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo {
|
||||||
job.files[idx].status = FileStatus::Printing;
|
job.files[idx].status = FileStatus::Printing;
|
||||||
job.current = Some(idx);
|
job.current = Some(idx);
|
||||||
job.phase = JobPhase::Printing;
|
job.phase = JobPhase::Printing;
|
||||||
job.cups_id = None;
|
job.cups_id = None;
|
||||||
|
job.duplex_first_pass_done = false;
|
||||||
SpawnInfo {
|
SpawnInfo {
|
||||||
job_id: job.id.clone(),
|
job_id: job.id.clone(),
|
||||||
idx,
|
idx,
|
||||||
@ -355,12 +395,7 @@ fn begin_next_locked(job: &mut Job, from: usize) -> Option<SpawnInfo> {
|
|||||||
match (from + 1..job.files.len()).find(|&i| job.files[i].status == FileStatus::Queued) {
|
match (from + 1..job.files.len()).find(|&i| job.files[i].status == FileStatus::Queued) {
|
||||||
Some(i) => {
|
Some(i) => {
|
||||||
job.current = Some(i);
|
job.current = Some(i);
|
||||||
if is_duplex_and_needs_clear(job, i) {
|
Some(start_printing_locked(job, i))
|
||||||
job.phase = JobPhase::AwaitingClearOutput;
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(start_printing_locked(job, i))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
job.phase = JobPhase::Finished;
|
job.phase = JobPhase::Finished;
|
||||||
@ -370,27 +405,27 @@ fn begin_next_locked(job: &mut Job, from: usize) -> Option<SpawnInfo> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_duplex_and_needs_clear(job: &Job, next_idx: usize) -> bool {
|
|
||||||
let next_file = &job.files[next_idx];
|
|
||||||
let is_duplex = next_file.settings.as_ref()
|
|
||||||
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
is_duplex && next_idx > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_print_task(store: JobStore, info: SpawnInfo) {
|
fn spawn_print_task(store: JobStore, info: SpawnInfo) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let path = info.path.clone();
|
let path = info.path.clone();
|
||||||
let settings = info.settings.clone();
|
let settings = info.settings.clone();
|
||||||
let is_duplex = settings.as_ref().map(|s| s.sides.as_deref() == Some("Двусторонняя")).unwrap_or(false);
|
let is_duplex = settings.as_ref()
|
||||||
|
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let duplex_pass: Option<&str> = if is_duplex {
|
let duplex_pass: Option<&str> = if is_duplex {
|
||||||
if info.is_second_pass { Some("even") } else { Some("odd") }
|
if info.is_second_pass { Some("even") } else { Some("odd") }
|
||||||
} else { None };
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let p = path.clone();
|
let p = path.clone();
|
||||||
let s = settings.clone();
|
let s = settings.clone();
|
||||||
let dp = duplex_pass.map(|d| d.to_string());
|
let dp = duplex_pass.map(|d| d.to_string());
|
||||||
let spool = tokio::task::spawn_blocking(move || run_lp(&p, s.as_ref(), dp.as_deref())).await;
|
|
||||||
|
let spool = tokio::task::spawn_blocking(move || {
|
||||||
|
run_lp(&p, s.as_ref(), dp.as_deref())
|
||||||
|
}).await;
|
||||||
|
|
||||||
let cups_id = match spool {
|
let cups_id = match spool {
|
||||||
Ok(Ok(id)) => id,
|
Ok(Ok(id)) => id,
|
||||||
@ -408,12 +443,18 @@ fn spawn_print_task(store: JobStore, info: SpawnInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if is_duplex && !info.is_second_pass {
|
if is_duplex && !info.is_second_pass {
|
||||||
job.files[info.idx].status = FileStatus::AwaitingFlip;
|
// ── Первый проход дуплекса завершён ──
|
||||||
job.phase = JobPhase::AwaitingFlip;
|
// Просим пользователя подтвердить что печать закончена
|
||||||
} else {
|
job.files[info.idx].status = FileStatus::AwaitingPickup;
|
||||||
job.files[info.idx].status = FileStatus::Sent;
|
|
||||||
job.phase = JobPhase::AwaitingPickup;
|
job.phase = JobPhase::AwaitingPickup;
|
||||||
|
job.duplex_first_pass_done = true;
|
||||||
|
} else {
|
||||||
|
// ── Обычная печать или второй проход дуплекса ──
|
||||||
|
job.files[info.idx].status = FileStatus::AwaitingPickup;
|
||||||
|
job.phase = JobPhase::AwaitingPickup;
|
||||||
|
job.duplex_first_pass_done = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
job.cups_id = None;
|
job.cups_id = None;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -423,6 +464,7 @@ async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String)
|
|||||||
let mut m = store.lock().unwrap();
|
let mut m = store.lock().unwrap();
|
||||||
let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return };
|
let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return };
|
||||||
if job.phase == JobPhase::Cancelled { return; }
|
if job.phase == JobPhase::Cancelled { return; }
|
||||||
|
|
||||||
if let Some(f) = job.files.get_mut(info.idx) {
|
if let Some(f) = job.files.get_mut(info.idx) {
|
||||||
if f.status != FileStatus::Printing { return; }
|
if f.status != FileStatus::Printing { return; }
|
||||||
f.status = FileStatus::Error;
|
f.status = FileStatus::Error;
|
||||||
@ -430,10 +472,12 @@ async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String)
|
|||||||
}
|
}
|
||||||
begin_next_locked(job, info.idx)
|
begin_next_locked(job, info.idx)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(si) = next { spawn_print_task(store, si); }
|
if let Some(si) = next { spawn_print_task(store, si); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────── lp / CUPS ───────────────────────────
|
// ─────────────────────────── lp / CUPS ───────────────────────────
|
||||||
|
|
||||||
fn cups_bin(name: &str) -> std::path::PathBuf {
|
fn cups_bin(name: &str) -> std::path::PathBuf {
|
||||||
let candidates = [
|
let candidates = [
|
||||||
format!("/pkg/gnu/cups/bin/{}", name),
|
format!("/pkg/gnu/cups/bin/{}", name),
|
||||||
@ -459,39 +503,66 @@ fn normalize_format(format: &str) -> String {
|
|||||||
fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&str>) -> Result<String, String> {
|
fn run_lp(path: &Path, settings: Option<&PrintSettings>, duplex_pass: Option<&str>) -> Result<String, String> {
|
||||||
let lp = cups_bin("lp");
|
let lp = cups_bin("lp");
|
||||||
let mut cmd = Command::new(&lp);
|
let mut cmd = Command::new(&lp);
|
||||||
|
|
||||||
if let Some(s) = settings {
|
if let Some(s) = settings {
|
||||||
cmd.arg("-n").arg(s.copies.to_string());
|
cmd.arg("-n").arg(s.copies.to_string());
|
||||||
if s.pages != "all" && !s.pages.is_empty() { cmd.arg("-P").arg(&s.pages); }
|
if s.pages != "all" && !s.pages.is_empty() {
|
||||||
if s.color_mode == "bw" { cmd.arg("-o").arg("ColorModel=Gray"); }
|
cmd.arg("-P").arg(&s.pages);
|
||||||
|
}
|
||||||
|
if s.color_mode == "bw" {
|
||||||
|
cmd.arg("-o").arg("ColorModel=Gray");
|
||||||
|
}
|
||||||
let fmt = normalize_format(&s.format);
|
let fmt = normalize_format(&s.format);
|
||||||
if !fmt.is_empty() { cmd.arg("-o").arg(format!("PageSize={}", fmt)); }
|
if !fmt.is_empty() {
|
||||||
|
cmd.arg("-o").arg(format!("PageSize={}", fmt));
|
||||||
|
}
|
||||||
if s.sides.as_deref() == Some("Двусторонняя") && duplex_pass.is_none() {
|
if s.sides.as_deref() == Some("Двусторонняя") && duplex_pass.is_none() {
|
||||||
cmd.arg("-o").arg("sides=two-sided-long-edge");
|
cmd.arg("-o").arg("sides=two-sided-long-edge");
|
||||||
}
|
}
|
||||||
if let Some(d) = s.dpi.as_deref() {
|
if let Some(d) = s.dpi.as_deref() {
|
||||||
if !d.is_empty() && d != "Авто" { cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); }
|
if !d.is_empty() && d != "Авто" {
|
||||||
|
cmd.arg("-o").arg(format!("printer-resolution={}dpi", d));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// СЛОТЫ: Upper для обычной, Lower для ручной (дуплекс)
|
// ──────────────────────────────────────────────────────────
|
||||||
if let Some(pass) = duplex_pass {
|
// СЛОТЫ:
|
||||||
cmd.arg("-o").arg("InputSlot=Lower");
|
// • Первый проход дуплекса (odd) → Upper
|
||||||
if pass == "even" { cmd.arg("-o").arg("orientation-requested=6"); }
|
// • Второй проход дуплекса (even) → Lower (ручная подача)
|
||||||
} else {
|
// • Обычная печать (не дуплекс) → Upper
|
||||||
cmd.arg("-o").arg("InputSlot=Upper");
|
// ──────────────────────────────────────────────────────────
|
||||||
}
|
|
||||||
|
|
||||||
match duplex_pass {
|
match duplex_pass {
|
||||||
Some("odd") => { cmd.arg("-o").arg("page-set=odd"); }
|
Some("odd") => {
|
||||||
|
// ПЕРВЫЙ проход — обычный лоток
|
||||||
|
cmd.arg("-o").arg("InputSlot=Upper");
|
||||||
|
cmd.arg("-o").arg("page-set=odd");
|
||||||
|
}
|
||||||
Some("even") => {
|
Some("even") => {
|
||||||
|
// ВТОРОЙ проход — ручная подача
|
||||||
|
cmd.arg("-o").arg("InputSlot=Lower");
|
||||||
cmd.arg("-o").arg("page-set=even");
|
cmd.arg("-o").arg("page-set=even");
|
||||||
cmd.arg("-o").arg("outputorder=reverse");
|
cmd.arg("-o").arg("outputorder=reverse");
|
||||||
|
cmd.arg("-o").arg("orientation-requested=6");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Обычная односторонняя печать
|
||||||
|
cmd.arg("-o").arg("InputSlot=Upper");
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.arg(path);
|
cmd.arg(path);
|
||||||
let out = cmd.env("LC_ALL", "C").output().map_err(|e| format!("lp: {}", e))?;
|
|
||||||
if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); }
|
let out = cmd.env("LC_ALL", "C").output()
|
||||||
Ok(String::from_utf8_lossy(&out.stdout).split_whitespace().nth(3).unwrap_or("").to_string())
|
.map_err(|e| format!("lp: {}", e))?;
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout)
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(3)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user