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
+83 -18
View File
@@ -52,13 +52,21 @@ impl<'a> Class2Send<'a> {
tracing::info!("Starting Class 2 fax to {}", number);
// Initialize modem first
self.at().send_command("AT", 3000)?;
tracing::debug!("Modem initialized");
let _ = self.at().send_command("ATZ", 3000);
std::thread::sleep(std::time::Duration::from_millis(1000));
self.at().send_command("AT", 5000)?;
self.at().send_command("ATE0", 5000)?;
// Hardware flow control
let _ = self.at().send_command("AT+IFC=2,2", 5000);
// Enter Class 2 mode
self.at().send_command(Class2Commands::ENTER_CLASS2, 5000)?;
tracing::info!("Entered Class 2 mode");
// Enable capabilities report (efax does this — required by many Class 2 modems)
let _ = self.at().send_command("AT+FCR=1", 3000);
// Set station ID
let station_cmd = Class2Commands::set_station_id(&self.station_id);
self.at().send_command(&station_cmd, 3000)?;
@@ -70,11 +78,20 @@ impl<'a> Class2Send<'a> {
tracing::warn!("Header command not supported, skipping");
}
// Set capabilities - skip if not supported
// Set DCE capabilities (NOT FDIS!) using +FDCC per efax's approach.
// +FDCC tells the modem what it CAN do; +FDIS sets session params.
// Let the modem use its defaults — only override if explicitly configured.
// efax: "+FDCC=%d,%d,%d,%d,%d,%d,%d,%d"
let speed = if self.params.speed > 0 { self.params.speed } else { BR_V17_14400 };
let dis_cmd = Class2Commands::set_dis_params(self.params.resolution, speed, self.params.ecm);
if self.at().send_command(&dis_cmd, 3000).is_err() {
tracing::warn!("DIS params command not supported, using defaults");
let vr = if self.params.resolution { 1 } else { 0 };
let ec = if self.params.ecm { 1 } else { 0 };
let fdcc_cmd = format!("AT+FDCC={},{},0,2,0,{},0,0", vr, speed, ec);
tracing::info!("Setting DCE capabilities: {}", fdcc_cmd);
if self.at().send_command(&fdcc_cmd, 3000).is_err() {
tracing::warn!("FDCC command not supported, trying FDIS...");
// Fallback: some modems only support FDIS
let fdis_cmd = Class2Commands::set_dis_params(self.params.resolution, speed, self.params.ecm);
let _ = self.at().send_command(&fdis_cmd, 3000);
}
// Dial
@@ -164,20 +181,23 @@ impl<'a> Class2Send<'a> {
fn send_page(&mut self, page: &Page) -> Result<()> {
// Start page transmission
// efax uses TO_C2B = 450 (45 seconds) for FDT timeout
self.driver.write_raw(b"AT+FDT\r")?;
self.driver.flush()?;
// Wait for CONNECT with longer timeout and retry logic
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
// Wait for CONNECT with 45s timeout (per efax TO_C2B)
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(45);
let mut got_connect = false;
while std::time::Instant::now() < deadline {
let response = match self.driver.read_until(b"\r\n", 5000) {
Ok(r) => r,
Err(crate::error::FaxError::Timeout { .. }) => continue,
Err(crate::error::FaxError::Timeout { .. }) => {
continue;
}
Err(e) => return Err(e),
};
let text = String::from_utf8_lossy(&response);
tracing::debug!("FDT response: {}", text);
tracing::info!("FDT response: {}", text.trim());
if text.contains("CONNECT") {
got_connect = true;
@@ -186,10 +206,41 @@ impl<'a> Class2Send<'a> {
if text.contains("ERROR") {
return Err(FaxError::modem("FDT command failed"));
}
if text.contains("+FCON") {
tracing::info!("Got +FCON instead of CONNECT for FDT");
got_connect = true;
break;
}
if text.contains("+FHNG:") {
return Err(FaxError::protocol(format!("Fax hangup before page: {}", text.trim())));
}
}
if !got_connect {
return Err(FaxError::Timeout { ms: 30000 });
return Err(FaxError::Timeout { ms: 45000 });
}
// Wait for XON/DC2 to begin data transfer (efax getstartc())
// Class 2 modems send XON (0x11) or DC2 (0x12) when ready for data
tracing::info!("Waiting for XON/DC2...");
let xon_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut got_xon = false;
while std::time::Instant::now() < xon_deadline {
match self.driver.read_byte(500) {
Ok(b) => {
if b == 0x11 || b == 0x12 { // XON or DC2
tracing::info!("Got XON/DC2 (0x{:02X})", b);
got_xon = true;
break;
}
tracing::debug!("Pre-XON byte: 0x{:02X}", b);
}
Err(crate::error::FaxError::Timeout { .. }) => continue,
Err(e) => return Err(e),
}
}
if !got_xon {
tracing::warn!("No XON/DC2 received, proceeding anyway");
}
// Encode pixel data to T.4 MH format
@@ -204,8 +255,8 @@ impl<'a> Class2Send<'a> {
self.driver.write_raw(&DLE_ETX)?;
self.driver.flush()?;
// Wait for page confirmation
let result = self.driver.read_until(b"\r\n", 30000)?;
// Wait for page confirmation — can take >60s at 4800 bps for large pages
let result = self.driver.read_until(b"\r\n", 180000)?;
let result_text = String::from_utf8_lossy(&result);
tracing::info!("Page result: {}", result_text);
@@ -223,12 +274,26 @@ impl<'a> Class2Send<'a> {
self.driver.write_raw(b"\r")?;
self.driver.flush()?;
// Wait for confirmation
let response = self.driver.read_until(b"\r\n", 10000)?;
tracing::debug!("End page response: {}", String::from_utf8_lossy(&response));
// Wait for final OK — the modem may send multiple lines (+FPTS, OK)
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while std::time::Instant::now() < deadline {
let response = match self.driver.read_until(b"\r\n", 5000) {
Ok(r) => r,
Err(crate::error::FaxError::Timeout { .. }) => continue,
Err(e) => return Err(e),
};
let text = String::from_utf8_lossy(&response).trim().to_string();
tracing::debug!("End page response: {}", text);
if text == "OK" || text == "OK\r" {
break;
}
if text.contains("ERROR") || text.contains("+FHNG:") {
return Err(FaxError::protocol(format!("End page failed: {}", text)));
}
}
// Brief pause before next page
std::thread::sleep(std::time::Duration::from_millis(500));
// Allow modem time to prepare for next page
std::thread::sleep(std::time::Duration::from_millis(1500));
self.driver.drain()?;
Ok(())