V1.0: Class 1 fax — real-world 4-page send to external number confirmed

Core features:
- Class 1 T.30 protocol: full send/receive implementation
- HDLC: DLE-stuffing, FCS strip, USR5637 bit-reversal handling
- T.4 MH encoder/decoder (1728px A4 standard)
- Document pipeline: PDF (Ghostscript), PNG, TIFF input
- Width clamping: US Letter 1734px → 1728px fax standard
- Cover page: CJK rasterization (TW/CN/JP/EN), TIFF + HTML output
- OCR verification: Tesseract 5 with eng+chi_tra, CJK space-tolerant
- API server (axum): health, send, jobs, cover, retry, cancel
- Background worker: auto-poll queue, speed fallback, retry policy
- Modem detection, pool management

Real-world test results (2026-07-23):
- V90 → 25153038: 4 pages, V.17 12000 bps, 2:33 ✅
- USR5637 → 25153038: 4 pages, V.17 12000 bps, 2:26 ✅
- Both faxes confirmed received on remote machine

Tested: loopback (100% pixel match), multi-page, all input formats,
cover pages, OCR verify, API endpoints, worker processing.
13 unit tests pass, 0 new clippy warnings.
This commit is contained in:
Warren
2026-07-24 18:47:15 +08:00
parent d1e92b32fb
commit 55bca92691
155 changed files with 25024 additions and 916 deletions
+228 -40
View File
@@ -1,44 +1,67 @@
use axum::{
extract::State,
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post, put},
routing::{delete, get, post, put},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::info;
use crate::config::FaxConfig;
use crate::config_new::AppConfig;
use crate::error::Result as FaxResult;
use crate::queue::job::{FaxJob, JobId};
use crate::queue::store::FaxQueue;
use crate::queue::{FaxJob, FaxQueue, JobId, JobStatus};
use crate::worker::FaxWorker;
#[derive(Clone)]
pub struct AppState {
pub config: FaxConfig,
pub config: AppConfig,
pub queue: Arc<Mutex<FaxQueue>>,
pub worker: Arc<Mutex<Option<FaxWorker>>>,
}
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub uptime_seconds: u64,
pub modems: Vec<ModemHealthInfo>,
pub queue: QueueHealthInfo,
}
#[derive(Serialize)]
pub struct ModemHealthInfo {
pub name: String,
pub device: String,
pub status: String,
}
#[derive(Serialize)]
pub struct QueueHealthInfo {
pub pending: usize,
pub active: usize,
pub failed: usize,
}
#[derive(Deserialize)]
pub struct SendFaxRequest {
pub recipient: String,
pub document_path: String,
#[serde(default)]
pub cover_to: Option<String>,
#[serde(default)]
pub cover_from: Option<String>,
#[serde(default)]
pub cover_subject: Option<String>,
#[serde(default)]
pub cover_notes: Option<String>,
}
#[derive(Serialize)]
pub struct SendFaxResponse {
pub job_id: JobId,
pub message: String,
}
#[derive(Deserialize)]
@@ -64,24 +87,57 @@ pub struct JobResponse {
pub recipient: String,
pub status: String,
pub pages: u32,
pub retries: u8,
pub created_at: String,
pub updated_at: String,
pub cover_to: Option<String>,
pub cover_from: Option<String>,
pub cover_subject: Option<String>,
pub cover_notes: Option<String>,
}
#[derive(Serialize)]
pub struct JobListResponse {
pub jobs: Vec<JobResponse>,
pub total: usize,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
}
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
let modems: Vec<ModemHealthInfo> = state.config.modems.iter().map(|m| {
ModemHealthInfo {
name: m.name.clone(),
device: m.device.clone(),
status: "idle".to_string(),
}
}).collect();
let queue = state.queue.lock().await;
let jobs = queue.list();
let pending = jobs.iter().filter(|j| matches!(j.status, JobStatus::Queued)).count();
let failed = jobs.iter().filter(|j| matches!(j.status, JobStatus::Failed(_))).count();
Json(HealthResponse {
status: "ok".to_string(),
device: state.config.device.clone(),
status: "healthy".to_string(),
uptime_seconds: 0,
modems,
queue: QueueHealthInfo {
pending,
active: 0,
failed,
},
})
}
async fn send_fax(
State(state): State<AppState>,
Json(req): Json<SendFaxRequest>,
) -> Result<Json<SendFaxResponse>, StatusCode> {
) -> Result<Json<SendFaxResponse>, (StatusCode, Json<ErrorResponse>)> {
let mut job = FaxJob::new(req.recipient, req.document_path);
job.cover_to = req.cover_to;
job.cover_from = req.cover_from;
@@ -89,16 +145,30 @@ async fn send_fax(
job.cover_notes = req.cover_notes;
let job_id = job.id;
let mut queue = state.queue.lock().unwrap();
queue.enqueue(job).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut queue = state.queue.lock().await;
queue.enqueue(job).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "queue_error".to_string(),
message: e.to_string(),
}),
)
})?;
info!(job_id = %job_id, "Fax job queued");
Ok(Json(SendFaxResponse { job_id }))
info!(job_id = %job_id, "Fax job queued via API");
Ok(Json(SendFaxResponse {
job_id,
message: "Job queued successfully".to_string(),
}))
}
async fn list_jobs(State(state): State<AppState>) -> Json<Vec<JobResponse>> {
let queue = state.queue.lock().unwrap();
async fn list_jobs(State(state): State<AppState>) -> Json<JobListResponse> {
let queue = state.queue.lock().await;
let jobs = queue.list();
let total = jobs.len();
let responses: Vec<JobResponse> = jobs
.into_iter()
.map(|j| JobResponse {
@@ -106,46 +176,132 @@ async fn list_jobs(State(state): State<AppState>) -> Json<Vec<JobResponse>> {
recipient: j.recipient,
status: format!("{:?}", j.status),
pages: j.pages,
retries: j.retries,
created_at: j.created_at.to_rfc3339(),
updated_at: j.updated_at.to_rfc3339(),
cover_to: j.cover_to,
cover_from: j.cover_from,
cover_subject: j.cover_subject,
cover_notes: j.cover_notes,
})
.collect();
Json(responses)
Json(JobListResponse { jobs: responses, total })
}
async fn get_job(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<JobId>,
) -> Result<Json<JobResponse>, StatusCode> {
let queue = state.queue.lock().unwrap();
Path(id): Path<JobId>,
) -> Result<Json<JobResponse>, (StatusCode, Json<ErrorResponse>)> {
let queue = state.queue.lock().await;
queue.get(&id).map(|j| Json(JobResponse {
id: j.id,
recipient: j.recipient,
status: format!("{:?}", j.status),
pages: j.pages,
retries: j.retries,
created_at: j.created_at.to_rfc3339(),
updated_at: j.updated_at.to_rfc3339(),
cover_to: j.cover_to,
cover_from: j.cover_from,
cover_subject: j.cover_subject,
cover_notes: j.cover_notes,
})).ok_or(StatusCode::NOT_FOUND)
})).ok_or((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "not_found".to_string(),
message: format!("Job {} not found", id),
}),
))
}
async fn cancel_job(
State(state): State<AppState>,
Path(id): Path<JobId>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let mut queue = state.queue.lock().await;
if queue.get(&id).is_none() {
return Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "not_found".to_string(),
message: format!("Job {} not found", id),
}),
));
}
queue.update_status(&id, JobStatus::Cancelled).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "update_failed".to_string(),
message: e.to_string(),
}),
)
})?;
info!(job_id = %id, "Job cancelled via API");
Ok(StatusCode::NO_CONTENT)
}
async fn retry_job(
State(state): State<AppState>,
Path(id): Path<JobId>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let mut queue = state.queue.lock().await;
if queue.get(&id).is_none() {
return Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "not_found".to_string(),
message: format!("Job {} not found", id),
}),
));
}
queue.update_status(&id, JobStatus::Queued).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "update_failed".to_string(),
message: e.to_string(),
}),
)
})?;
info!(job_id = %id, "Job retry requested via API");
Ok(StatusCode::OK)
}
async fn update_job_cover(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<JobId>,
Path(id): Path<JobId>,
Json(req): Json<UpdateCoverRequest>,
) -> Result<Json<UpdateCoverResponse>, StatusCode> {
let mut queue = state.queue.lock().unwrap();
) -> Result<Json<UpdateCoverResponse>, (StatusCode, Json<ErrorResponse>)> {
let mut queue = state.queue.lock().await;
queue
.update_cover(&id, req.to.clone(), req.from.clone(), req.subject.clone(), req.notes.clone())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "update_failed".to_string(),
message: e.to_string(),
}),
)
})?;
let job = queue.get(&id).ok_or((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "not_found".to_string(),
message: format!("Job {} not found", id),
}),
))?;
// Return updated fields
let job = queue.get(&id).ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(UpdateCoverResponse {
job_id: job.id,
cover_to: job.cover_to,
@@ -155,24 +311,56 @@ async fn update_job_cover(
}))
}
pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> FaxResult<()> {
let state = AppState {
config: config.clone(),
queue,
};
let app = Router::new()
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/api/health", get(health))
.route("/api/fax/send", post(send_fax))
.route("/api/fax/jobs", get(list_jobs))
.route("/api/fax/jobs/{id}", get(get_job))
.route("/api/fax/jobs/{id}", delete(cancel_job))
.route("/api/fax/jobs/{id}/retry", post(retry_job))
.route("/api/fax/jobs/{id}/cover", put(update_job_cover))
.with_state(state);
.with_state(state)
}
let addr = config
.api_listen
.clone()
.unwrap_or_else(|| "0.0.0.0:3000".to_string());
pub async fn start_server(config: AppConfig, queue: Arc<Mutex<FaxQueue>>) -> FaxResult<()> {
let fax_config = if let Some(modem) = config.primary_modem() {
crate::config::FaxConfig {
device: modem.device.clone(),
baud_rate: 115200,
station_id: config.fax.station_id.clone(),
header: config.fax.header.clone(),
..Default::default()
}
} else {
crate::config::FaxConfig::default()
};
let worker = crate::worker::FaxWorker::new(fax_config, queue.clone());
let worker_handle = Arc::new(Mutex::new(Some(worker)));
// Spawn the worker in the background before moving into state
// Take the worker out of the Mutex so we don't hold the lock during the loop
{
let mut w = worker_handle.lock().await;
if let Some(worker) = w.take() {
tokio::spawn(async move {
if let Err(e) = worker.start().await {
tracing::error!("Worker error: {}", e);
}
});
}
}
let state = AppState {
config: config.clone(),
queue,
worker: worker_handle,
};
let app = create_router(state);
let addr = config.server.listen.clone();
info!("Starting API server on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr)
@@ -184,4 +372,4 @@ pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> Fax
.map_err(|e| crate::error::FaxError::Other(format!("Server error: {}", e)))?;
Ok(())
}
}