Dualside printing test realisation
This commit is contained in:
192
src/main.rs
192
src/main.rs
@ -25,7 +25,7 @@ use tracing::{error, info, warn};
|
|||||||
|
|
||||||
async fn get_version() -> impl IntoResponse {
|
async fn get_version() -> impl IntoResponse {
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"timestamp": chrono::Utc::now().timestamp()
|
"timestamp": chrono::Utc::now().timestamp()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -51,6 +51,7 @@ struct PrintSettings {
|
|||||||
enum FileStatus {
|
enum FileStatus {
|
||||||
Queued,
|
Queued,
|
||||||
Printing,
|
Printing,
|
||||||
|
AwaitingFlip,
|
||||||
Awaiting,
|
Awaiting,
|
||||||
Done,
|
Done,
|
||||||
Error,
|
Error,
|
||||||
@ -62,6 +63,7 @@ enum FileStatus {
|
|||||||
enum JobPhase {
|
enum JobPhase {
|
||||||
WaitingStart,
|
WaitingStart,
|
||||||
Printing,
|
Printing,
|
||||||
|
AwaitingFlip,
|
||||||
AwaitingPickup,
|
AwaitingPickup,
|
||||||
Finished,
|
Finished,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
@ -73,6 +75,9 @@ struct JobFileView {
|
|||||||
status: FileStatus,
|
status: FileStatus,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
/// true → нечётное кол-во страниц, нужно убрать верхний лист перед 2-м проходом
|
||||||
|
#[serde(default)]
|
||||||
|
remove_top_sheet: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@ -103,17 +108,29 @@ impl Job {
|
|||||||
JobView {
|
JobView {
|
||||||
job_id: self.id.clone(),
|
job_id: self.id.clone(),
|
||||||
phase: self.phase.clone(),
|
phase: self.phase.clone(),
|
||||||
files: self
|
files: self.files.iter().map(|f| {
|
||||||
.files
|
JobFileView {
|
||||||
.iter()
|
|
||||||
.map(|f| JobFileView {
|
|
||||||
name: f.name.clone(),
|
name: f.name.clone(),
|
||||||
status: f.status.clone(),
|
status: f.status.clone(),
|
||||||
error: f.error.clone(),
|
error: f.error.clone(),
|
||||||
})
|
remove_top_sheet: Self::should_remove_top_sheet(&f.settings),
|
||||||
.collect(),
|
}
|
||||||
|
}).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Нечетных страниц больше, чем чётных → лишний лист сверху
|
||||||
|
fn should_remove_top_sheet(settings: &Option<PrintSettings>) -> bool {
|
||||||
|
let Some(s) = settings else { return false };
|
||||||
|
if s.sides.as_deref() != Some("Двусторонняя") { return false; }
|
||||||
|
if s.pages == "all" { return false; } // для офисных не знаем кол-во
|
||||||
|
let nums: Vec<u32> = s.pages.split(',')
|
||||||
|
.filter_map(|p| p.trim().parse().ok())
|
||||||
|
.collect();
|
||||||
|
let odd = nums.iter().filter(|n| *n % 2 == 1).count();
|
||||||
|
let even = nums.iter().filter(|n| *n % 2 == 0).count();
|
||||||
|
odd > even
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -122,6 +139,7 @@ struct SpawnInfo {
|
|||||||
idx: usize,
|
idx: usize,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
settings: Option<PrintSettings>,
|
settings: Option<PrintSettings>,
|
||||||
|
is_second_pass: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobStore = Arc<Mutex<HashMap<String, Job>>>;
|
type JobStore = Arc<Mutex<HashMap<String, Job>>>;
|
||||||
@ -279,30 +297,48 @@ async fn job_advance(
|
|||||||
) -> Result<Json<JobView>, (StatusCode, String)> {
|
) -> Result<Json<JobView>, (StatusCode, String)> {
|
||||||
let (view, spawn_info) = {
|
let (view, spawn_info) = {
|
||||||
let mut m = store.lock().unwrap();
|
let mut m = store.lock().unwrap();
|
||||||
let job = m
|
let job = m.get_mut(&job_id)
|
||||||
.get_mut(&job_id)
|
|
||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?;
|
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?;
|
||||||
|
|
||||||
let si = match job.phase {
|
let si = match job.phase {
|
||||||
JobPhase::WaitingStart => {
|
JobPhase::WaitingStart => {
|
||||||
info!(job_id = %job_id, "▶️ Клиент дал старт: печать первого файла");
|
info!(job_id = %job_id, "▶️ Старт первого файла");
|
||||||
Some(start_printing_locked(job, 0))
|
Some(start_printing_locked(job, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JobPhase::AwaitingFlip => {
|
||||||
|
if let Some(c) = job.current {
|
||||||
|
info!(job_id = %job_id, file = %job.files[c].name,
|
||||||
|
"🔄 Запуск второго прохода (чётные страницы)");
|
||||||
|
job.files[c].status = FileStatus::Printing;
|
||||||
|
job.phase = JobPhase::Printing;
|
||||||
|
job.cups_id = None;
|
||||||
|
Some(SpawnInfo {
|
||||||
|
job_id: job.id.clone(),
|
||||||
|
idx: c,
|
||||||
|
path: job.files[c].path.to_path_buf(),
|
||||||
|
settings: job.files[c].settings.clone(),
|
||||||
|
is_second_pass: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
JobPhase::AwaitingPickup => match job.current.take() {
|
JobPhase::AwaitingPickup => match job.current.take() {
|
||||||
Some(c) => {
|
Some(c) => {
|
||||||
job.files[c].status = FileStatus::Done;
|
job.files[c].status = FileStatus::Done;
|
||||||
info!(job_id = %job_id, file = %job.files[c].name, "✔️ Пользователь забрал документ");
|
info!(job_id = %job_id, file = %job.files[c].name, "✔️ Документ забран");
|
||||||
begin_next_locked(job, c)
|
begin_next_locked(job, c)
|
||||||
}
|
}
|
||||||
None => {
|
None => { job.phase = JobPhase::Finished; None }
|
||||||
job.phase = JobPhase::Finished;
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// printing / finished / cancelled — идемпотентно игнорируем
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
(job.view(), si)
|
(job.view(), si)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(si) = spawn_info {
|
if let Some(si) = spawn_info {
|
||||||
spawn_print_task(store.clone(), si);
|
spawn_print_task(store.clone(), si);
|
||||||
}
|
}
|
||||||
@ -362,6 +398,7 @@ fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo {
|
|||||||
idx,
|
idx,
|
||||||
path: job.files[idx].path.to_path_buf(),
|
path: job.files[idx].path.to_path_buf(),
|
||||||
settings: job.files[idx].settings.clone(),
|
settings: job.files[idx].settings.clone(),
|
||||||
|
is_second_pass: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,57 +417,74 @@ fn begin_next_locked(job: &mut Job, from: usize) -> Option<SpawnInfo> {
|
|||||||
|
|
||||||
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 duplex_pass: Option<&str> = if is_duplex {
|
||||||
|
if info.is_second_pass { Some("even") } else { Some("odd") }
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// 1) Спулим в CUPS
|
// 1) Спулим в CUPS
|
||||||
let spool =
|
let p = path.clone();
|
||||||
tokio::task::spawn_blocking(move || run_lp(&path, settings.as_ref())).await;
|
let s = settings.clone();
|
||||||
|
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 cups_id = match spool {
|
let cups_id = match spool {
|
||||||
Ok(Ok(id)) => id,
|
Ok(Ok(id)) => id,
|
||||||
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
|
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
|
||||||
Err(e) => {
|
Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
|
||||||
return mark_error_and_continue(store, info, format!("spawn: {}", e)).await
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2) Ждём физического завершения печати
|
// 2) Ждём физического завершения
|
||||||
if !cups_id.is_empty() {
|
if !cups_id.is_empty() {
|
||||||
{
|
{
|
||||||
let mut m = store.lock().unwrap();
|
let mut m = store.lock().unwrap();
|
||||||
match m.get_mut(&info.job_id) {
|
if let Some(job) = m.get_mut(&info.job_id) {
|
||||||
Some(job) if job.phase != JobPhase::Cancelled => {
|
if job.phase != JobPhase::Cancelled {
|
||||||
job.cups_id = Some(cups_id.clone())
|
job.cups_id = Some(cups_id.clone());
|
||||||
}
|
} else { return; }
|
||||||
_ => return,
|
} else { return; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let cid = cups_id.clone();
|
let cid = cups_id.clone();
|
||||||
let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await;
|
let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await;
|
||||||
match wait {
|
match wait {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
|
Ok(Err(e)) => return mark_error_and_continue(store, info, e).await,
|
||||||
Err(e) => {
|
Err(e) => return mark_error_and_continue(store, info, format!("spawn: {}", e)).await,
|
||||||
return mark_error_and_continue(store, info, format!("spawn: {}", e)).await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Допечатано → ждём подтверждения пользователя
|
// 3) Обновляем статус
|
||||||
let mut m = store.lock().unwrap();
|
let mut m = store.lock().unwrap();
|
||||||
let job = match m.get_mut(&info.job_id) {
|
let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return };
|
||||||
Some(j) => j,
|
|
||||||
None => return,
|
if job.phase == JobPhase::Cancelled { return; }
|
||||||
};
|
if job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; }
|
||||||
if job.phase == JobPhase::Cancelled
|
|
||||||
|| job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing)
|
if is_duplex && !info.is_second_pass {
|
||||||
{
|
// Первый проход завершён → ждём, пока пользователь переложит бумагу
|
||||||
return;
|
job.files[info.idx].status = FileStatus::AwaitingFlip;
|
||||||
|
job.phase = JobPhase::AwaitingFlip;
|
||||||
|
job.cups_id = None;
|
||||||
|
info!(job_id = %info.job_id, file = %job.files[info.idx].name,
|
||||||
|
"🔄 Первый проход завершён, ждём переворота бумаги");
|
||||||
|
} else {
|
||||||
|
// Обычная печать или второй проход дуплекса → ждём, пока заберут
|
||||||
|
job.files[info.idx].status = FileStatus::Awaiting;
|
||||||
|
job.phase = JobPhase::AwaitingPickup;
|
||||||
|
job.cups_id = None;
|
||||||
|
info!(job_id = %info.job_id, file = %job.files[info.idx].name,
|
||||||
|
"✅ Печать завершена, ждём подтверждения");
|
||||||
}
|
}
|
||||||
job.files[info.idx].status = FileStatus::Awaiting;
|
|
||||||
job.phase = JobPhase::AwaitingPickup;
|
|
||||||
job.cups_id = None;
|
|
||||||
info!(job_id = %info.job_id, file = %job.files[info.idx].name, "✅ Файл допечатан, ждём подтверждения клиента");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -487,11 +541,13 @@ fn normalize_format(format: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_lp(path: &Path, settings: Option<&PrintSettings>) -> 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() {
|
if s.pages != "all" && !s.pages.is_empty() {
|
||||||
cmd.arg("-P").arg(&s.pages);
|
cmd.arg("-P").arg(&s.pages);
|
||||||
}
|
}
|
||||||
@ -502,40 +558,42 @@ fn run_lp(path: &Path, settings: Option<&PrintSettings>) -> Result<String, Strin
|
|||||||
if !fmt.is_empty() {
|
if !fmt.is_empty() {
|
||||||
cmd.arg("-o").arg(format!("PageSize={}", fmt));
|
cmd.arg("-o").arg(format!("PageSize={}", fmt));
|
||||||
}
|
}
|
||||||
if s.sides.as_deref() == Some("Двусторонняя") {
|
|
||||||
|
// Аппаратный дуплекс только если НЕ ручная двухпроходная печать
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
match s.dpi.as_deref() {
|
|
||||||
Some(d) if !d.is_empty() && d != "Авто" => {
|
if let Some(d) = s.dpi.as_deref() {
|
||||||
|
if !d.is_empty() && d != "Авто" {
|
||||||
cmd.arg("-o").arg(format!("printer-resolution={}dpi", d));
|
cmd.arg("-o").arg(format!("printer-resolution={}dpi", d));
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ручной дуплекс: разбивка на чёт/нечёт
|
||||||
|
match duplex_pass {
|
||||||
|
Some("odd") => {
|
||||||
|
cmd.arg("-o").arg("page-set=odd");
|
||||||
|
}
|
||||||
|
Some("even") => {
|
||||||
|
cmd.arg("-o").arg("page-set=even");
|
||||||
|
cmd.arg("-o").arg("outputorder=reverse");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
cmd.arg(path);
|
cmd.arg(path);
|
||||||
|
|
||||||
let args: Vec<String> = cmd
|
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect();
|
||||||
.get_args()
|
info!(command = format!("{} {}", lp.display(), args.join(" ")), "🖨️ Команда печати");
|
||||||
.map(|a| a.to_string_lossy().to_string())
|
|
||||||
.collect();
|
|
||||||
info!(
|
|
||||||
command = format!("{} {}", lp.display(), args.join(" ")),
|
|
||||||
"🖨️ Выполнение команды печати"
|
|
||||||
);
|
|
||||||
|
|
||||||
let out = cmd
|
let out = cmd.output().map_err(|e| format!("Не удалось запустить lp: {}", e))?;
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("Не удалось запустить lp: {}", e))?;
|
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||||
}
|
}
|
||||||
// stdout: "request id is PRINTER-123 (1 file(s))"
|
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
Ok(stdout
|
Ok(stdout.split_whitespace().nth(3).unwrap_or("").to_string())
|
||||||
.split_whitespace()
|
|
||||||
.nth(3)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lpstat_raw(which: &str) -> Option<String> {
|
fn lpstat_raw(which: &str) -> Option<String> {
|
||||||
|
|||||||
Reference in New Issue
Block a user