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:
lan
2025-12-14 22:04:27 +08:00
parent 5789a4346a
commit 3a9aaef996
12 changed files with 1515 additions and 86 deletions

777
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@ tokio-stream = "0.1"
hostname = "0.3" hostname = "0.3"
sha2 = "0.10" sha2 = "0.10"
url = "2" url = "2"
snmp = "0.2"
reqwest = { version = "0.12", features = ["rustls-tls", "stream"] }
[build-dependencies] [build-dependencies]
tonic-build = "0.11" tonic-build = "0.11"

View File

@@ -4,6 +4,8 @@ fn main() {
std::env::set_var("PROTOC", protoc); std::env::set_var("PROTOC", protoc);
} }
println!("cargo:rerun-if-changed=proto/control.proto");
tonic_build::configure() tonic_build::configure()
.build_server(false) .build_server(false)
.compile(&["proto/control.proto"], &["proto"]) .compile(&["proto/control.proto"], &["proto"])

View File

@@ -39,8 +39,12 @@ message Pong {
message PrintInstruction { message PrintInstruction {
string request_id = 1; string request_id = 1;
bytes pdf_data = 2; bytes pdf_data = 2; // 兼容旧字段,仍可用
PrintParams params = 3; 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 { message PrintParams {
@@ -78,5 +82,7 @@ message PrinterInfo {
uint32 ppm = 9; uint32 ppm = 9;
uint32 ppm_color = 10; uint32 ppm_color = 10;
repeated string reasons = 11; repeated string reasons = 11;
int32 paper_percent = 12; // -1 或缺省表示未知
uint64 page_count = 13; // 总页数0 或缺省表示未知
} }

271
src/bin/cups_smoke.rs Normal file
View 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(), &params, 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)),
}
}

View File

@@ -13,6 +13,8 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub cups: CupsConfig, pub cups: CupsConfig,
#[serde(default)] #[serde(default)]
pub snmp: SnmpConfig,
#[serde(default)]
pub scheduler: SchedulerConfig, pub scheduler: SchedulerConfig,
#[serde(default)] #[serde(default)]
pub retry: RetryConfig, pub retry: RetryConfig,
@@ -38,6 +40,22 @@ pub struct CupsConfig {
pub password: Option<String>, 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)] #[derive(Debug, Clone, Deserialize)]
pub struct SchedulerConfig { pub struct SchedulerConfig {
#[serde(default = "default_strategy")] #[serde(default = "default_strategy")]
@@ -59,7 +77,7 @@ pub struct SchedulerConfig {
pub printer_speeds: std::collections::HashMap<String, PrinterSpeedOverride>, 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")] #[serde(rename_all = "snake_case")]
pub enum LoadBalanceStrategy { pub enum LoadBalanceStrategy {
RoundRobin, 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 { impl Default for SchedulerConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -161,6 +190,22 @@ fn default_refresh_secs() -> u64 {
10 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 { fn default_ppm_bw() -> u32 {
30 30
} }

View File

@@ -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 anyhow::{anyhow, Context, Result};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tokio::{ use tokio::io::AsyncReadExt;
io::AsyncWriteExt, use tokio::time::sleep;
task::JoinHandle,
time::{sleep, timeout},
};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -22,6 +25,7 @@ use crate::{
scheduler::Scheduler, scheduler::Scheduler,
}; };
use cups_rs::PrinterState; use cups_rs::PrinterState;
use reqwest::StatusCode;
const RECONNECT_BASE_MS: u64 = 1000; const RECONNECT_BASE_MS: u64 = 1000;
const RECONNECT_MAX_MS: u64 = 10000; const RECONNECT_MAX_MS: u64 = 10000;
@@ -73,7 +77,11 @@ impl ControlService {
hostname_value.clone() hostname_value.clone()
}; };
// send hello info!(
client_id = %client_id_value,
printers = printer_payload.len(),
"发送 hello 到控制端"
);
let hello = ClientMessage { let hello = ClientMessage {
msg: Some(client_message::Msg::Hello(Hello { msg: Some(client_message::Msg::Hello(Hello {
client_id: client_id_value, client_id: client_id_value,
@@ -138,7 +146,7 @@ async fn handle_print(
.ok_or_else(|| anyhow!("缺少打印参数"))?; .ok_or_else(|| anyhow!("缺少打印参数"))?;
let params = map_params(params_proto)?; let params = map_params(params_proto)?;
let temp = TempPdf::new(instr.pdf_data).await?; let temp = materialize_pdf(&instr).await?;
let result = scheduler let result = scheduler
.dispatch( .dispatch(
temp.path(), temp.path(),
@@ -265,6 +273,8 @@ fn to_proto_printer(p: &PrinterInfo) -> ProtoPrinter {
ppm: p.ppm.unwrap_or_default(), ppm: p.ppm.unwrap_or_default(),
ppm_color: p.ppm_color.unwrap_or_default(), ppm_color: p.ppm_color.unwrap_or_default(),
reasons: p.reasons.clone(), 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 { struct TempPdf {
path: PathBuf, path: PathBuf,
_handle: JoinHandle<Result<()>>, _file: NamedTempFile,
} }
impl TempPdf { impl TempPdf {
async fn new(data: Vec<u8>) -> Result<Self> { async fn from_bytes(data: &[u8]) -> Result<Self> {
let (tx, rx) = tokio::sync::oneshot::channel(); let mut file = NamedTempFile::new().context("创建临时文件失败")?;
let handle = tokio::spawn(async move { file.write_all(data)
let file = NamedTempFile::new().context("创建临时文件失败")?; .context("写入临时文件失败")?;
let mut writer = tokio::fs::File::from_std(file.reopen().context("打开临时文件失败")?); file.flush().context("刷新临时文件失败")?;
writer let path = file.path().to_path_buf();
.write_all(&data) Ok(Self { path, _file: file })
.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,
})
} }
fn path(&self) -> &std::path::Path { fn path(&self) -> &std::path::Path {
@@ -368,3 +358,48 @@ fn hostname() -> Result<String> {
.map(|s| s.to_string_lossy().to_string()) .map(|s| s.to_string_lossy().to_string())
.context("读取主机名失败") .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
View File

@@ -0,0 +1,8 @@
pub mod config;
pub mod control_client;
pub mod models;
pub mod printer;
pub mod proto;
pub mod scheduler;

View File

@@ -1,16 +1,12 @@
mod config;
mod control_client;
mod models;
mod printer;
mod proto;
mod scheduler;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use control_client::ControlService; use print_backend::{
use printer::{CupsClient, SharedPrinterAdapter}; config,
use scheduler::Scheduler; control_client::ControlService,
printer::{CupsClient, SharedPrinterAdapter},
scheduler::Scheduler,
};
use tracing_subscriber::{fmt, EnvFilter}; use tracing_subscriber::{fmt, EnvFilter};
#[tokio::main] #[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( let scheduler = Arc::new(Scheduler::new(
config.scheduler.strategy, config.scheduler.strategy,
config.scheduler.refresh_interval_secs, config.scheduler.refresh_interval_secs,

View File

@@ -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 anyhow::{Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
@@ -12,6 +12,7 @@ use tokio::task::spawn_blocking;
use tracing::warn; use tracing::warn;
use url::Url; use url::Url;
use crate::config::SnmpConfig;
use crate::models::{PrintDefaults, PrintParams}; use crate::models::{PrintDefaults, PrintParams};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -27,6 +28,8 @@ pub struct PrinterInfo {
pub device_uri: Option<String>, pub device_uri: Option<String>,
pub host: Option<String>, pub host: Option<String>,
pub accepting_jobs: bool, pub accepting_jobs: bool,
pub paper_percent: Option<i32>,
pub page_count: Option<u64>,
} }
impl PrinterInfo { impl PrinterInfo {
@@ -65,12 +68,21 @@ pub trait PrintAdapter: Send + Sync {
} }
#[derive(Debug, Default)] #[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] #[async_trait]
impl PrintAdapter for CupsClient { impl PrintAdapter for CupsClient {
async fn list_printers(&self) -> Result<Vec<PrinterInfo>> { 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 destinations = get_all_destinations().context("获取打印机列表失败")?;
let mut infos = Vec::with_capacity(destinations.len()); let mut infos = Vec::with_capacity(destinations.len());
@@ -78,14 +90,7 @@ impl PrintAdapter for CupsClient {
let state = dest.state(); let state = dest.state();
let reasons = dest.state_reasons(); let reasons = dest.state_reasons();
let options = dest.get_options(); let options = dest.get_options();
let color_supported = options let color_supported = detect_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 ppm = parse_speed_option(options.get("printer-speed")); let ppm = parse_speed_option(options.get("printer-speed"));
let ppm_color = parse_speed_option( let ppm_color = parse_speed_option(
options options
@@ -107,6 +112,17 @@ impl PrintAdapter for CupsClient {
let id = derive_printer_id(&dest, device_uri.as_deref(), host.as_deref()); let id = derive_printer_id(&dest, device_uri.as_deref(), host.as_deref());
let accepting_jobs = dest.is_accepting_jobs(); 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 { infos.push(PrinterInfo {
id, id,
dest, dest,
@@ -119,6 +135,8 @@ impl PrintAdapter for CupsClient {
device_uri, device_uri,
host, host,
accepting_jobs, accepting_jobs,
paper_percent,
page_count,
}); });
} }
Ok::<_, anyhow::Error>(infos) Ok::<_, anyhow::Error>(infos)
@@ -213,8 +231,195 @@ impl PartialEq for PrinterInfo {
&& self.device_uri == other.device_uri && self.device_uri == other.device_uri
&& self.host == other.host && self.host == other.host
&& self.accepting_jobs == other.accepting_jobs && self.accepting_jobs == other.accepting_jobs
&& self.paper_percent == other.paper_percent
&& self.page_count == other.page_count
} }
} }
impl Eq for PrinterInfo {} 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
}

View File

@@ -352,6 +352,8 @@ mod tests {
device_uri: None, device_uri: None,
host: None, host: None,
accepting_jobs: true, accepting_jobs: true,
paper_percent: None,
page_count: None,
} }
} }

132
yipe/grpc_summary.md Normal file
View 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;
}
```