Enhance printing functionality with SNMP support and update dependencies
- Added SNMP configuration options to `AppConfig` for querying printer status. - Extended `PrintInstruction` in the proto file to support additional fields for remote file URLs and raw data. - Implemented logic in `CupsClient` to retrieve printer paper levels and page counts via SNMP. - Updated `Cargo.toml` to include `snmp` and `reqwest` dependencies for network communication. - Refactored `TempPdf` handling to support multiple data sources for printing. - Improved error handling and logging for better diagnostics during print operations.
This commit is contained in:
777
Cargo.lock
generated
777
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ tokio-stream = "0.1"
|
||||
hostname = "0.3"
|
||||
sha2 = "0.10"
|
||||
url = "2"
|
||||
snmp = "0.2"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "stream"] }
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.11"
|
||||
|
||||
2
build.rs
2
build.rs
@@ -4,6 +4,8 @@ fn main() {
|
||||
std::env::set_var("PROTOC", protoc);
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-changed=proto/control.proto");
|
||||
|
||||
tonic_build::configure()
|
||||
.build_server(false)
|
||||
.compile(&["proto/control.proto"], &["proto"])
|
||||
|
||||
@@ -39,8 +39,12 @@ message Pong {
|
||||
|
||||
message PrintInstruction {
|
||||
string request_id = 1;
|
||||
bytes pdf_data = 2;
|
||||
bytes pdf_data = 2; // 兼容旧字段,仍可用
|
||||
PrintParams params = 3;
|
||||
string url = 4; // 新:远程文件 URL
|
||||
bytes raw_data = 5; // 新:任意二进制(预期 PDF)
|
||||
string local_path = 6; // 新:本地路径(客户端可读)
|
||||
string content_type = 7; // 可选 MIME 类型,默认 application/pdf
|
||||
}
|
||||
|
||||
message PrintParams {
|
||||
@@ -78,5 +82,7 @@ message PrinterInfo {
|
||||
uint32 ppm = 9;
|
||||
uint32 ppm_color = 10;
|
||||
repeated string reasons = 11;
|
||||
int32 paper_percent = 12; // -1 或缺省表示未知
|
||||
uint64 page_count = 13; // 总页数,0 或缺省表示未知
|
||||
}
|
||||
|
||||
|
||||
271
src/bin/cups_smoke.rs
Normal file
271
src/bin/cups_smoke.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use print_backend::{
|
||||
config::{AppConfig, LoadBalanceStrategy},
|
||||
models::{ColorModeSetting, DuplexSetting, OrientationSetting, PrintParams, QualitySetting},
|
||||
printer::{CupsClient, SharedPrinterAdapter},
|
||||
scheduler::Scheduler,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse(std::env::args().skip(1))?;
|
||||
|
||||
let config_path = std::env::var("CONFIG_PATH").ok();
|
||||
let config = AppConfig::load(config_path.as_deref())?;
|
||||
|
||||
configure_cups(&config)?;
|
||||
|
||||
let adapter: SharedPrinterAdapter = Arc::new(CupsClient::with_snmp(Some(config.snmp.clone())));
|
||||
let scheduler = Arc::new(Scheduler::new(
|
||||
config.scheduler.strategy,
|
||||
config.scheduler.refresh_interval_secs,
|
||||
adapter,
|
||||
config.scheduler.default_ppm_bw,
|
||||
config.scheduler.default_ppm_color,
|
||||
config.scheduler.target_wait_minutes,
|
||||
config.scheduler.printer_speeds.clone(),
|
||||
));
|
||||
|
||||
if args.list {
|
||||
list_printers(&scheduler).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let file = args.file.clone();
|
||||
if let Some(file) = file {
|
||||
let params = args.into_print_params();
|
||||
let retry = &config.retry;
|
||||
let defaults = &config.defaults;
|
||||
let target = params.printer.as_deref();
|
||||
|
||||
let (job, retries_used) = scheduler
|
||||
.dispatch_with_target(file.as_path(), ¶ms, defaults, retry, target)
|
||||
.await
|
||||
.context("提交打印任务失败")?;
|
||||
|
||||
println!(
|
||||
"打印成功: printer={}, job_id={}, retries_used={}",
|
||||
job.printer, job.job_id, retries_used
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Args::print_usage();
|
||||
Err(anyhow!("未指定操作 (--list 或 --print)"))
|
||||
}
|
||||
|
||||
fn configure_cups(config: &AppConfig) -> Result<()> {
|
||||
if let Some(server) = &config.cups.server {
|
||||
let normalized = normalize_server(server);
|
||||
unsafe {
|
||||
std::env::set_var("CUPS_SERVER", &normalized);
|
||||
}
|
||||
cups_rs::config::set_server(Some(&normalized)).context("设置 CUPS 服务器失败")?;
|
||||
}
|
||||
if let Some(user) = &config.cups.user {
|
||||
unsafe {
|
||||
std::env::set_var("CUPS_USER", user);
|
||||
}
|
||||
cups_rs::config::set_user(Some(user)).context("设置 CUPS 用户失败")?;
|
||||
}
|
||||
if let Some(password) = &config.cups.password {
|
||||
unsafe {
|
||||
std::env::set_var("CUPS_PASSWORD", password);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_server(input: &str) -> String {
|
||||
if let Some((_, rest)) = input.split_once("://") {
|
||||
rest.split('/').next().unwrap_or(rest).to_string()
|
||||
} else {
|
||||
input.split('/').next().unwrap_or(input).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_printers(scheduler: &Scheduler) -> Result<()> {
|
||||
let printers = scheduler
|
||||
.refresh_snapshot()
|
||||
.await
|
||||
.context("获取打印机列表失败")?;
|
||||
|
||||
if printers.is_empty() {
|
||||
println!("未发现打印机");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("发现 {} 台打印机:", printers.len());
|
||||
for p in printers {
|
||||
println!(
|
||||
"- name: {}\n id: {}\n state: {:?}\n accepting_jobs: {}\n active_jobs: {}\n color_supported: {}\n ppm: {:?}, ppm_color: {:?}\n paper_percent: {:?}\n page_count: {:?}\n reasons: {:?}\n",
|
||||
p.dest.name,
|
||||
p.id,
|
||||
p.state,
|
||||
p.accepting_jobs,
|
||||
p.active_jobs,
|
||||
p.color_supported,
|
||||
p.ppm,
|
||||
p.ppm_color,
|
||||
p.paper_percent,
|
||||
p.page_count,
|
||||
p.reasons
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Args {
|
||||
list: bool,
|
||||
file: Option<PathBuf>,
|
||||
printer: Option<String>,
|
||||
copies: Option<u32>,
|
||||
color: Option<ColorModeSetting>,
|
||||
duplex: Option<DuplexSetting>,
|
||||
media: Option<String>,
|
||||
quality: Option<QualitySetting>,
|
||||
orientation: Option<OrientationSetting>,
|
||||
job_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse<I: Iterator<Item = String>>(mut iter: I) -> Result<Self> {
|
||||
let mut args = Args::default();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--list" => args.list = true,
|
||||
"--print" | "-f" | "--file" => {
|
||||
let v = iter.next().ok_or_else(|| anyhow!("--print 需要文件路径"))?;
|
||||
args.file = Some(PathBuf::from(v));
|
||||
}
|
||||
"--printer" | "-p" => {
|
||||
args.printer =
|
||||
Some(iter.next().ok_or_else(|| anyhow!("--printer 需要值"))?);
|
||||
}
|
||||
"--copies" => {
|
||||
let v = iter.next().ok_or_else(|| anyhow!("--copies 需要值"))?;
|
||||
args.copies = Some(v.parse()?);
|
||||
}
|
||||
"--color" => {
|
||||
let v = iter.next().ok_or_else(|| anyhow!("--color 需要值"))?;
|
||||
args.color = Some(parse_color(&v)?);
|
||||
}
|
||||
"--duplex" => {
|
||||
let v = iter.next().ok_or_else(|| anyhow!("--duplex 需要值"))?;
|
||||
args.duplex = Some(parse_duplex(&v)?);
|
||||
}
|
||||
"--media" => {
|
||||
args.media = Some(iter.next().ok_or_else(|| anyhow!("--media 需要值"))?);
|
||||
}
|
||||
"--quality" => {
|
||||
let v = iter.next().ok_or_else(|| anyhow!("--quality 需要值"))?;
|
||||
args.quality = Some(parse_quality(&v)?);
|
||||
}
|
||||
"--orientation" => {
|
||||
let v = iter.next().ok_or_else(|| anyhow!("--orientation 需要值"))?;
|
||||
args.orientation = Some(parse_orientation(&v)?);
|
||||
}
|
||||
"--job-name" => {
|
||||
args.job_name =
|
||||
Some(iter.next().ok_or_else(|| anyhow!("--job-name 需要值"))?);
|
||||
}
|
||||
"--strategy" => {
|
||||
// keep compatibility with config; no-op but accept for clarity
|
||||
let v = iter.next().ok_or_else(|| anyhow!("--strategy 需要值"))?;
|
||||
if v.to_lowercase() == "round_robin" {
|
||||
args.set_strategy(LoadBalanceStrategy::RoundRobin)?;
|
||||
} else if v.to_lowercase() == "least_queued" {
|
||||
args.set_strategy(LoadBalanceStrategy::LeastQueued)?;
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"不支持的策略: {} (可选: round_robin, least_queued)",
|
||||
v
|
||||
));
|
||||
}
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
Args::print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(anyhow!("未知参数: {}", other));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn into_print_params(mut self) -> PrintParams {
|
||||
let mut params = PrintParams {
|
||||
copies: self.copies,
|
||||
duplex: self.duplex,
|
||||
color: self.color,
|
||||
media: self.media.take(),
|
||||
quality: self.quality,
|
||||
orientation: self.orientation,
|
||||
job_name: self.job_name.take(),
|
||||
printer: self.printer.take(),
|
||||
};
|
||||
// convenience: allow -p to set printer
|
||||
if params.printer.is_none() {
|
||||
params.printer = self.printer.take();
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn set_strategy(&mut self, strategy: LoadBalanceStrategy) -> Result<()> {
|
||||
// Currently strategy is only configurable via config; accept CLI flag but ensure config matches.
|
||||
if strategy != LoadBalanceStrategy::LeastQueued {
|
||||
// Most users rely on config; warn if they expect change.
|
||||
println!("提示:调度策略请在 config.app.yaml 的 scheduler.strategy 中配置");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
println!(
|
||||
"用法:\n cups_smoke --list\n cups_smoke --print <文件路径> [选项]\n\n\
|
||||
选项:\n --printer, -p <名称> 指定打印机名称\n --copies <n> 份数\n --color <auto|color|monochrome>\n --duplex <one_sided|two_sided_long_edge|two_sided_short_edge>\n --media <介质>\n --quality <draft|normal|high>\n --orientation <portrait|landscape>\n --job-name <名称>\n --help, -h 显示帮助"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(input: &str) -> Result<ColorModeSetting> {
|
||||
match input.to_ascii_lowercase().as_str() {
|
||||
"auto" => Ok(ColorModeSetting::Auto),
|
||||
"color" => Ok(ColorModeSetting::Color),
|
||||
"monochrome" | "mono" | "bw" => Ok(ColorModeSetting::Monochrome),
|
||||
other => Err(anyhow!("无效的 color: {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_duplex(input: &str) -> Result<DuplexSetting> {
|
||||
match input.to_ascii_lowercase().as_str() {
|
||||
"one_sided" | "single" => Ok(DuplexSetting::OneSided),
|
||||
"two_sided_long_edge" | "long" => Ok(DuplexSetting::TwoSidedLongEdge),
|
||||
"two_sided_short_edge" | "short" => Ok(DuplexSetting::TwoSidedShortEdge),
|
||||
other => Err(anyhow!("无效的 duplex: {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_quality(input: &str) -> Result<QualitySetting> {
|
||||
match input.to_ascii_lowercase().as_str() {
|
||||
"draft" => Ok(QualitySetting::Draft),
|
||||
"normal" => Ok(QualitySetting::Normal),
|
||||
"high" => Ok(QualitySetting::High),
|
||||
other => Err(anyhow!("无效的 quality: {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_orientation(input: &str) -> Result<OrientationSetting> {
|
||||
match input.to_ascii_lowercase().as_str() {
|
||||
"portrait" => Ok(OrientationSetting::Portrait),
|
||||
"landscape" => Ok(OrientationSetting::Landscape),
|
||||
other => Err(anyhow!("无效的 orientation: {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub cups: CupsConfig,
|
||||
#[serde(default)]
|
||||
pub snmp: SnmpConfig,
|
||||
#[serde(default)]
|
||||
pub scheduler: SchedulerConfig,
|
||||
#[serde(default)]
|
||||
pub retry: RetryConfig,
|
||||
@@ -38,6 +40,22 @@ pub struct CupsConfig {
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SnmpConfig {
|
||||
/// 是否启用 SNMP 查询耗材/纸量
|
||||
#[serde(default = "default_snmp_enabled")]
|
||||
pub enabled: bool,
|
||||
/// SNMP community,默认 public
|
||||
#[serde(default = "default_snmp_community")]
|
||||
pub community: String,
|
||||
/// 查询超时(毫秒)
|
||||
#[serde(default = "default_snmp_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
/// 重试次数
|
||||
#[serde(default = "default_snmp_retries")]
|
||||
pub retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SchedulerConfig {
|
||||
#[serde(default = "default_strategy")]
|
||||
@@ -59,7 +77,7 @@ pub struct SchedulerConfig {
|
||||
pub printer_speeds: std::collections::HashMap<String, PrinterSpeedOverride>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Copy)]
|
||||
#[derive(Debug, Clone, Deserialize, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoadBalanceStrategy {
|
||||
RoundRobin,
|
||||
@@ -104,6 +122,17 @@ impl Default for CupsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SnmpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_snmp_enabled(),
|
||||
community: default_snmp_community(),
|
||||
timeout_ms: default_snmp_timeout_ms(),
|
||||
retries: default_snmp_retries(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SchedulerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -161,6 +190,22 @@ fn default_refresh_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_snmp_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_snmp_community() -> String {
|
||||
"public".to_string()
|
||||
}
|
||||
|
||||
fn default_snmp_timeout_ms() -> u64 {
|
||||
1500
|
||||
}
|
||||
|
||||
fn default_snmp_retries() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_ppm_bw() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::{
|
||||
io::AsyncWriteExt,
|
||||
task::JoinHandle,
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::time::sleep;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -22,6 +25,7 @@ use crate::{
|
||||
scheduler::Scheduler,
|
||||
};
|
||||
use cups_rs::PrinterState;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
const RECONNECT_BASE_MS: u64 = 1000;
|
||||
const RECONNECT_MAX_MS: u64 = 10000;
|
||||
@@ -73,7 +77,11 @@ impl ControlService {
|
||||
hostname_value.clone()
|
||||
};
|
||||
|
||||
// send hello
|
||||
info!(
|
||||
client_id = %client_id_value,
|
||||
printers = printer_payload.len(),
|
||||
"发送 hello 到控制端"
|
||||
);
|
||||
let hello = ClientMessage {
|
||||
msg: Some(client_message::Msg::Hello(Hello {
|
||||
client_id: client_id_value,
|
||||
@@ -138,7 +146,7 @@ async fn handle_print(
|
||||
.ok_or_else(|| anyhow!("缺少打印参数"))?;
|
||||
let params = map_params(params_proto)?;
|
||||
|
||||
let temp = TempPdf::new(instr.pdf_data).await?;
|
||||
let temp = materialize_pdf(&instr).await?;
|
||||
let result = scheduler
|
||||
.dispatch(
|
||||
temp.path(),
|
||||
@@ -265,6 +273,8 @@ fn to_proto_printer(p: &PrinterInfo) -> ProtoPrinter {
|
||||
ppm: p.ppm.unwrap_or_default(),
|
||||
ppm_color: p.ppm_color.unwrap_or_default(),
|
||||
reasons: p.reasons.clone(),
|
||||
paper_percent: p.paper_percent.unwrap_or(-1),
|
||||
page_count: p.page_count.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,37 +335,17 @@ fn map_params(params: &ProtoPrintParams) -> Result<PrintParams> {
|
||||
|
||||
struct TempPdf {
|
||||
path: PathBuf,
|
||||
_handle: JoinHandle<Result<()>>,
|
||||
_file: NamedTempFile,
|
||||
}
|
||||
|
||||
impl TempPdf {
|
||||
async fn new(data: Vec<u8>) -> Result<Self> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
let file = NamedTempFile::new().context("创建临时文件失败")?;
|
||||
let mut writer = tokio::fs::File::from_std(file.reopen().context("打开临时文件失败")?);
|
||||
writer
|
||||
.write_all(&data)
|
||||
.await
|
||||
.context("写入临时文件失败")?;
|
||||
writer.flush().await.context("刷新临时文件失败")?;
|
||||
let path = file.path().to_path_buf();
|
||||
// Hold file until drop to keep on disk
|
||||
tx.send(path).ok();
|
||||
// keep file alive
|
||||
sleep(Duration::from_secs(300)).await;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let path = timeout(Duration::from_secs(5), rx)
|
||||
.await
|
||||
.context("等待临时文件路径超时")?
|
||||
.context("获取临时文件路径失败")?;
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
_handle: handle,
|
||||
})
|
||||
async fn from_bytes(data: &[u8]) -> Result<Self> {
|
||||
let mut file = NamedTempFile::new().context("创建临时文件失败")?;
|
||||
file.write_all(data)
|
||||
.context("写入临时文件失败")?;
|
||||
file.flush().context("刷新临时文件失败")?;
|
||||
let path = file.path().to_path_buf();
|
||||
Ok(Self { path, _file: file })
|
||||
}
|
||||
|
||||
fn path(&self) -> &std::path::Path {
|
||||
@@ -368,3 +358,48 @@ fn hostname() -> Result<String> {
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.context("读取主机名失败")
|
||||
}
|
||||
|
||||
async fn materialize_pdf(instr: &PrintInstruction) -> Result<TempPdf> {
|
||||
if !instr.pdf_data.is_empty() {
|
||||
return TempPdf::from_bytes(&instr.pdf_data).await;
|
||||
}
|
||||
if !instr.raw_data.is_empty() {
|
||||
return TempPdf::from_bytes(&instr.raw_data).await;
|
||||
}
|
||||
if !instr.url.is_empty() {
|
||||
return TempPdf::from_url(&instr.url).await;
|
||||
}
|
||||
if !instr.local_path.is_empty() {
|
||||
return TempPdf::from_path(&instr.local_path).await;
|
||||
}
|
||||
Err(anyhow!("未提供可用的打印数据(pdf_data/raw_data/url/local_path)"))
|
||||
}
|
||||
|
||||
impl TempPdf {
|
||||
async fn from_url(url: &str) -> Result<Self> {
|
||||
let resp = reqwest::get(url).await.context("下载打印文件失败")?;
|
||||
if resp.status() == StatusCode::NO_CONTENT {
|
||||
return Err(anyhow!("下载文件为空"));
|
||||
}
|
||||
let resp = resp.error_for_status().context("下载文件返回错误状态")?;
|
||||
let bytes = resp.bytes().await.context("读取下载内容失败")?;
|
||||
if bytes.is_empty() {
|
||||
return Err(anyhow!("下载文件为空"));
|
||||
}
|
||||
Self::from_bytes(&bytes).await
|
||||
}
|
||||
|
||||
async fn from_path(path: &str) -> Result<Self> {
|
||||
let mut file = tokio::fs::File::open(path)
|
||||
.await
|
||||
.with_context(|| format!("打开打印文件失败: {}", path))?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.await
|
||||
.with_context(|| format!("读取打印文件失败: {}", path))?;
|
||||
if buf.is_empty() {
|
||||
return Err(anyhow!("读取文件为空"));
|
||||
}
|
||||
Self::from_bytes(&buf).await
|
||||
}
|
||||
}
|
||||
|
||||
8
src/lib.rs
Normal file
8
src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod config;
|
||||
pub mod control_client;
|
||||
pub mod models;
|
||||
pub mod printer;
|
||||
pub mod proto;
|
||||
pub mod scheduler;
|
||||
|
||||
|
||||
18
src/main.rs
18
src/main.rs
@@ -1,16 +1,12 @@
|
||||
mod config;
|
||||
mod control_client;
|
||||
mod models;
|
||||
mod printer;
|
||||
mod proto;
|
||||
mod scheduler;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use control_client::ControlService;
|
||||
use printer::{CupsClient, SharedPrinterAdapter};
|
||||
use scheduler::Scheduler;
|
||||
use print_backend::{
|
||||
config,
|
||||
control_client::ControlService,
|
||||
printer::{CupsClient, SharedPrinterAdapter},
|
||||
scheduler::Scheduler,
|
||||
};
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
|
||||
#[tokio::main]
|
||||
@@ -41,7 +37,7 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
}
|
||||
}
|
||||
|
||||
let adapter: SharedPrinterAdapter = Arc::new(CupsClient::default());
|
||||
let adapter: SharedPrinterAdapter = Arc::new(CupsClient::with_snmp(Some(config.snmp.clone())));
|
||||
let scheduler = Arc::new(Scheduler::new(
|
||||
config.scheduler.strategy,
|
||||
config.scheduler.refresh_interval_secs,
|
||||
|
||||
227
src/printer.rs
227
src/printer.rs
@@ -1,4 +1,4 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
use std::{collections::HashMap, path::Path, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
@@ -12,6 +12,7 @@ use tokio::task::spawn_blocking;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
use crate::config::SnmpConfig;
|
||||
use crate::models::{PrintDefaults, PrintParams};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -27,6 +28,8 @@ pub struct PrinterInfo {
|
||||
pub device_uri: Option<String>,
|
||||
pub host: Option<String>,
|
||||
pub accepting_jobs: bool,
|
||||
pub paper_percent: Option<i32>,
|
||||
pub page_count: Option<u64>,
|
||||
}
|
||||
|
||||
impl PrinterInfo {
|
||||
@@ -65,12 +68,21 @@ pub trait PrintAdapter: Send + Sync {
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CupsClient;
|
||||
pub struct CupsClient {
|
||||
snmp: Option<SnmpConfig>,
|
||||
}
|
||||
|
||||
impl CupsClient {
|
||||
pub fn with_snmp(snmp: Option<SnmpConfig>) -> Self {
|
||||
Self { snmp }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PrintAdapter for CupsClient {
|
||||
async fn list_printers(&self) -> Result<Vec<PrinterInfo>> {
|
||||
spawn_blocking(|| {
|
||||
let snmp_cfg = self.snmp.clone();
|
||||
spawn_blocking(move || {
|
||||
let destinations = get_all_destinations().context("获取打印机列表失败")?;
|
||||
|
||||
let mut infos = Vec::with_capacity(destinations.len());
|
||||
@@ -78,14 +90,7 @@ impl PrintAdapter for CupsClient {
|
||||
let state = dest.state();
|
||||
let reasons = dest.state_reasons();
|
||||
let options = dest.get_options();
|
||||
let color_supported = options
|
||||
.get("print-color-mode-supported")
|
||||
.map(|modes| {
|
||||
modes
|
||||
.split(',')
|
||||
.any(|m| m.trim().eq_ignore_ascii_case("color"))
|
||||
})
|
||||
.unwrap_or(true);
|
||||
let color_supported = detect_color_supported(&options);
|
||||
let ppm = parse_speed_option(options.get("printer-speed"));
|
||||
let ppm_color = parse_speed_option(
|
||||
options
|
||||
@@ -107,6 +112,17 @@ impl PrintAdapter for CupsClient {
|
||||
let id = derive_printer_id(&dest, device_uri.as_deref(), host.as_deref());
|
||||
let accepting_jobs = dest.is_accepting_jobs();
|
||||
|
||||
let (paper_percent, page_count) = match (&snmp_cfg, host.as_deref()) {
|
||||
(Some(snmp_cfg), Some(h)) if snmp_cfg.enabled => match poll_snmp_paper_page(h, snmp_cfg) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("SNMP 查询 {} 失败: {}", h, e);
|
||||
(None, None)
|
||||
}
|
||||
},
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
infos.push(PrinterInfo {
|
||||
id,
|
||||
dest,
|
||||
@@ -119,6 +135,8 @@ impl PrintAdapter for CupsClient {
|
||||
device_uri,
|
||||
host,
|
||||
accepting_jobs,
|
||||
paper_percent,
|
||||
page_count,
|
||||
});
|
||||
}
|
||||
Ok::<_, anyhow::Error>(infos)
|
||||
@@ -213,8 +231,195 @@ impl PartialEq for PrinterInfo {
|
||||
&& self.device_uri == other.device_uri
|
||||
&& self.host == other.host
|
||||
&& self.accepting_jobs == other.accepting_jobs
|
||||
&& self.paper_percent == other.paper_percent
|
||||
&& self.page_count == other.page_count
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for PrinterInfo {}
|
||||
|
||||
fn detect_color_supported(options: &std::collections::HashMap<String, String>) -> bool {
|
||||
// Prefer explicit color mode indicators; otherwise be conservative (default false).
|
||||
for key in [
|
||||
"print-color-mode-supported",
|
||||
"printer-color-mode-supported",
|
||||
"color-supported",
|
||||
] {
|
||||
if let Some(v) = options.get(key) {
|
||||
let has_color = v
|
||||
.split(|c| c == ',' || c == ' ' || c == ';')
|
||||
.any(|m| {
|
||||
let l = m.trim().to_ascii_lowercase();
|
||||
l == "color" || l.contains("rgb") || l.contains("cmyk")
|
||||
});
|
||||
return has_color;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = options.get("ColorModel").or_else(|| options.get("color-models")) {
|
||||
let has_color = v
|
||||
.split(|c| c == ',' || c == ' ' || c == ';')
|
||||
.any(|m| {
|
||||
let l = m.trim().to_ascii_lowercase();
|
||||
l.contains("rgb") || l.contains("cmyk") || l == "color"
|
||||
});
|
||||
return has_color;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn poll_snmp_paper_page(host: &str, cfg: &SnmpConfig) -> Result<(Option<i32>, Option<u64>)> {
|
||||
use snmp::SyncSession;
|
||||
let addr = format!("{host}:161");
|
||||
let timeout = Duration::from_millis(cfg.timeout_ms);
|
||||
let mut session = SyncSession::new(
|
||||
addr.as_str(),
|
||||
cfg.community.as_bytes(),
|
||||
Some(timeout),
|
||||
cfg.retries as i32,
|
||||
)
|
||||
.context("创建 SNMP 会话失败")?;
|
||||
|
||||
let paper = compute_percent(
|
||||
&mut session,
|
||||
&[1, 3, 6, 1, 2, 1, 43, 8, 2, 1, 9, 1], // max: prtInputMaxCapacity
|
||||
&[1, 3, 6, 1, 2, 1, 43, 8, 2, 1, 10, 1], // level: prtInputCurrentLevel
|
||||
);
|
||||
let page_count = read_counter_first(
|
||||
&mut session,
|
||||
&[1, 3, 6, 1, 2, 1, 43, 10, 2, 1, 4, 1], // prtMarkerLifeCount
|
||||
);
|
||||
Ok((paper, page_count))
|
||||
}
|
||||
|
||||
fn compute_percent(
|
||||
session: &mut snmp::SyncSession,
|
||||
max_oid: &[u32],
|
||||
level_oid: &[u32],
|
||||
) -> Option<i32> {
|
||||
use snmp::{ObjIdBuf, Value};
|
||||
|
||||
let max_map = walk_i32(session, max_oid);
|
||||
if max_map.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut best: Option<i32> = None;
|
||||
let mut cursor = level_oid.to_vec();
|
||||
loop {
|
||||
let pdu = match session.getbulk(&[cursor.as_slice()], 0, 10) {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
let mut progressed = false;
|
||||
for (oid, val) in pdu.varbinds {
|
||||
let mut buf: ObjIdBuf = [0; 128];
|
||||
let name = match oid.read_name(&mut buf) {
|
||||
Ok(n) => n.to_vec(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !name.starts_with(level_oid) {
|
||||
return best;
|
||||
}
|
||||
progressed = true;
|
||||
cursor = name.clone();
|
||||
if let Value::Integer(level) = val {
|
||||
if level <= 0 {
|
||||
continue;
|
||||
}
|
||||
// find matching max by full oid or last index
|
||||
let max_v = max_map
|
||||
.get(&name)
|
||||
.or_else(|| {
|
||||
name.last().and_then(|last| {
|
||||
max_map
|
||||
.iter()
|
||||
.find(|(k, _)| k.last() == Some(last))
|
||||
.map(|(_, v)| v)
|
||||
})
|
||||
});
|
||||
if let Some(max_v) = max_v {
|
||||
if *max_v > 0 {
|
||||
let pct = ((level as f64) * 100.0 / (*max_v as f64)).round() as i32;
|
||||
best = Some(best.map_or(pct, |b| b.min(pct)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn walk_i32(session: &mut snmp::SyncSession, base: &[u32]) -> HashMap<Vec<u32>, i32> {
|
||||
use snmp::{ObjIdBuf, Value};
|
||||
|
||||
let mut results = HashMap::new();
|
||||
let mut cursor = base.to_vec();
|
||||
loop {
|
||||
let pdu = match session.getbulk(&[cursor.as_slice()], 0, 10) {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
let mut progressed = false;
|
||||
for (oid, val) in pdu.varbinds.clone() {
|
||||
let mut buf: ObjIdBuf = [0; 128];
|
||||
let name = match oid.read_name(&mut buf) {
|
||||
Ok(n) => n.to_vec(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !name.starts_with(base) {
|
||||
return results;
|
||||
}
|
||||
progressed = true;
|
||||
cursor = name.clone();
|
||||
if let Value::Integer(v) = val {
|
||||
if v > 0 && v <= i32::MAX as i64 {
|
||||
results.insert(name, v as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn read_counter_first(session: &mut snmp::SyncSession, base: &[u32]) -> Option<u64> {
|
||||
use snmp::{ObjIdBuf, Value};
|
||||
let mut cursor = base.to_vec();
|
||||
loop {
|
||||
let pdu = match session.getbulk(&[cursor.as_slice()], 0, 10) {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
let mut progressed = false;
|
||||
for (oid, val) in pdu.varbinds.clone() {
|
||||
let mut buf: ObjIdBuf = [0; 128];
|
||||
let name = match oid.read_name(&mut buf) {
|
||||
Ok(n) => n.to_vec(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !name.starts_with(base) {
|
||||
return None;
|
||||
}
|
||||
progressed = true;
|
||||
cursor = name.clone();
|
||||
match val {
|
||||
Value::Integer(v) if v >= 0 => return Some(v as u64),
|
||||
Value::Counter32(v) => return Some(v as u64),
|
||||
Value::Counter64(v) => return Some(v),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -352,6 +352,8 @@ mod tests {
|
||||
device_uri: None,
|
||||
host: None,
|
||||
accepting_jobs: true,
|
||||
paper_percent: None,
|
||||
page_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
132
yipe/grpc_summary.md
Normal file
132
yipe/grpc_summary.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# gRPC 控制流协议摘要(ControlService)
|
||||
|
||||
## 接口
|
||||
- 服务:`control.v1.Control`
|
||||
- RPC:`ControlStream(stream ClientMessage) returns (stream ServerMessage)`
|
||||
- 双向长连接,客户端与控制端可任意时序发消息。
|
||||
|
||||
## 消息结构(摘自 proto/control.proto)
|
||||
- `ClientMessage` oneof:
|
||||
- `Hello`:client_id、version、hostname、printers[]
|
||||
- `PrintResult`:request_id、ok、printer、message、retries_used
|
||||
- `Pong`:nonce(对应 Ping)
|
||||
- `PrinterUpdate`:printers[]
|
||||
- `ServerMessage` oneof:
|
||||
- `PrintInstruction`:request_id、pdf_data(bytes)、PrintParams
|
||||
- `Ping`:nonce
|
||||
- `PrintParams`:copies、duplex(one_sided/long_edge/short_edge)、color(auto/color/monochrome)、media、quality(draft/normal/high)、orientation(portrait/landscape)、job_name、printer(optional 指定目标打印机)
|
||||
- `PrinterInfo`:id、name、host、uri、state、accepting_jobs、color_supported、active_jobs、ppm、ppm_color、reasons[]
|
||||
|
||||
## 客户端行为(当前实现)
|
||||
- 建立流后立即发送 `Hello`,携带当前打印机快照。
|
||||
- 后台按配置刷新(默认 10s,最小 5s)检测变更,发送 `PrinterUpdate`。
|
||||
- 收到 `Ping{nonce}` 立即回 `Pong{nonce}`。
|
||||
- 收到 `PrintInstruction`:
|
||||
- 将 pdf_data 落盘临时文件。
|
||||
- 按调度策略(默认 least_queued,支持指定 printer;尊重彩色需求和可用性)分发到 CUPS。
|
||||
- 成功/失败后返回 `PrintResult{ok, printer, message, retries_used}`。
|
||||
- 连接出错自动指数退避重连(1s 起,最大 10s),控制端无需主动重连。
|
||||
|
||||
## 字段语义补充
|
||||
- `color_supported`:客户端基于 CUPS 选项判定,保守为 false 以避免误判。
|
||||
- `state`:CUPS 状态字符串(Idle/Processing/Stopped 等)。
|
||||
- `reasons`:CUPS 状态原因列表(offline/jam/paper-out 等)。
|
||||
- `ppm`/`ppm_color`:若 CUPS 未提供则为缺省/0。
|
||||
|
||||
## 控制端最小交互建议
|
||||
1) 等待首条 `Hello`,登记客户端与打印机列表。
|
||||
2) 定期/按需发送 `Ping` 保活。
|
||||
3) 下发打印用 `PrintInstruction`(带 request_id 以关联结果)。
|
||||
4) 接收 `PrintResult` 做业务回执;可订阅 `PrinterUpdate` 更新状态。
|
||||
|
||||
## 开发/测试命令
|
||||
- 生成/查看 proto:文件位于 `proto/control.proto`,由 `tonic-build` 在 `build.rs` 生成绑定。
|
||||
- 本地 smoke(无控制端):`LIBCLANG_PATH=/usr/lib CONFIG_PATH=config/app.yaml cargo run --bin cups_smoke -- --list` / `--print <file> -p <printer>`。
|
||||
|
||||
## 原始 proto 文件
|
||||
路径:`proto/control.proto`
|
||||
|
||||
```
|
||||
syntax = "proto3";
|
||||
|
||||
package control.v1;
|
||||
|
||||
service Control {
|
||||
rpc ControlStream(stream ClientMessage) returns (stream ServerMessage);
|
||||
}
|
||||
|
||||
message ClientMessage {
|
||||
oneof msg {
|
||||
Hello hello = 1;
|
||||
PrintResult result = 2;
|
||||
Pong pong = 3;
|
||||
PrinterUpdate printers = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message ServerMessage {
|
||||
oneof msg {
|
||||
PrintInstruction print = 1;
|
||||
Ping ping = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Hello {
|
||||
string client_id = 1;
|
||||
string version = 2;
|
||||
string hostname = 3;
|
||||
repeated PrinterInfo printers = 4;
|
||||
}
|
||||
|
||||
message Ping {
|
||||
string nonce = 1;
|
||||
}
|
||||
|
||||
message Pong {
|
||||
string nonce = 1;
|
||||
}
|
||||
|
||||
message PrintInstruction {
|
||||
string request_id = 1;
|
||||
bytes pdf_data = 2;
|
||||
PrintParams params = 3;
|
||||
}
|
||||
|
||||
message PrintParams {
|
||||
uint32 copies = 1;
|
||||
string duplex = 2; // one_sided, long_edge, short_edge
|
||||
string color = 3; // auto, color, monochrome
|
||||
string media = 4;
|
||||
string quality = 5; // draft, normal, high
|
||||
string orientation = 6; // portrait, landscape
|
||||
string job_name = 7;
|
||||
string printer = 8;
|
||||
}
|
||||
|
||||
message PrintResult {
|
||||
string request_id = 1;
|
||||
bool ok = 2;
|
||||
string printer = 3;
|
||||
string message = 4;
|
||||
uint32 retries_used = 5;
|
||||
}
|
||||
|
||||
message PrinterUpdate {
|
||||
repeated PrinterInfo printers = 1;
|
||||
}
|
||||
|
||||
message PrinterInfo {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string host = 3;
|
||||
string uri = 4;
|
||||
string state = 5;
|
||||
bool accepting_jobs = 6;
|
||||
bool color_supported = 7;
|
||||
uint32 active_jobs = 8;
|
||||
uint32 ppm = 9;
|
||||
uint32 ppm_color = 10;
|
||||
repeated string reasons = 11;
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user