55bca92691
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.
327 lines
12 KiB
Rust
327 lines
12 KiB
Rust
use crate::error::{FaxError, Result};
|
|
use crate::fax::class2::commands::{Class2Commands, DccParams, BR_V17_14400};
|
|
use crate::fax::encoder::MhEncoder;
|
|
use crate::modem::driver::ModemDriver;
|
|
use crate::document::convert::Page;
|
|
|
|
const DLE: u8 = 0x10;
|
|
const ETX: u8 = 0x03;
|
|
const DLE_ETX: [u8; 2] = [DLE, ETX];
|
|
|
|
/// Class 2 Fax Sender - simpler than Class 1
|
|
/// The modem handles T.30 protocol, HDLC framing, and negotiation automatically.
|
|
pub struct Class2Send<'a> {
|
|
driver: &'a mut ModemDriver,
|
|
station_id: String,
|
|
header: String,
|
|
params: DccParams,
|
|
}
|
|
|
|
impl<'a> Class2Send<'a> {
|
|
pub fn new(driver: &'a mut ModemDriver) -> Self {
|
|
Self {
|
|
driver,
|
|
station_id: "TELFAX".to_string(),
|
|
header: "Telfax Fax Server".to_string(),
|
|
params: DccParams::default(),
|
|
}
|
|
}
|
|
|
|
pub fn with_station_id(mut self, id: &str) -> Self {
|
|
self.station_id = id.to_string();
|
|
self
|
|
}
|
|
|
|
pub fn with_header(mut self, header: &str) -> Self {
|
|
self.header = header.to_string();
|
|
self
|
|
}
|
|
|
|
pub fn with_params(mut self, params: DccParams) -> Self {
|
|
self.params = params;
|
|
self
|
|
}
|
|
|
|
fn at(&mut self) -> crate::modem::at::AtChannel<'_> {
|
|
crate::modem::at::AtChannel::new(self.driver)
|
|
}
|
|
|
|
/// Send fax document using Class 2 protocol
|
|
/// Pages should be pre-encoded in T.4 MH format
|
|
pub fn send_fax(&mut self, number: &str, pages: &[Page]) -> Result<()> {
|
|
tracing::info!("Starting Class 2 fax to {}", number);
|
|
|
|
// Initialize modem first
|
|
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)?;
|
|
tracing::info!("Station ID set: {}", self.station_id);
|
|
|
|
// Set header - may not be supported on some modems
|
|
let header_cmd = Class2Commands::set_header(&self.header);
|
|
if self.at().send_command(&header_cmd, 3000).is_err() {
|
|
tracing::warn!("Header command not supported, skipping");
|
|
}
|
|
|
|
// 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 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
|
|
self.dial(number)?;
|
|
|
|
// Send pages
|
|
for (i, page) in pages.iter().enumerate() {
|
|
tracing::info!("Sending page {} of {}", i + 1, pages.len());
|
|
self.send_page(page)?;
|
|
|
|
let is_last = i == pages.len() - 1;
|
|
self.end_page(is_last)?;
|
|
}
|
|
|
|
// Hang up
|
|
self.hang_up()?;
|
|
tracing::info!("Fax sent successfully");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn dial(&mut self, number: &str) -> Result<()> {
|
|
self.driver.drain()?;
|
|
let cmd = Class2Commands::dial(number);
|
|
self.driver.write_raw(cmd.as_bytes())?;
|
|
self.driver.write_raw(b"\r")?;
|
|
self.driver.flush()?;
|
|
tracing::info!("Dialing: {}", cmd);
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
|
|
|
// Read lines until CONNECT or error
|
|
while std::time::Instant::now() < deadline {
|
|
let line = match self.driver.read_until(b"\r\n", 5000) {
|
|
Ok(l) => l,
|
|
Err(crate::error::FaxError::Timeout { .. }) => continue,
|
|
Err(e) => return Err(e),
|
|
};
|
|
let text = String::from_utf8_lossy(&line).trim().to_string();
|
|
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
tracing::info!("Dial response: {}", text);
|
|
|
|
// Skip command echo
|
|
if text.starts_with("ATDT") || text.starts_with("ATD") {
|
|
continue;
|
|
}
|
|
|
|
// Error conditions
|
|
if text.contains("NO CARRIER") || text.contains("BUSY") {
|
|
return Err(FaxError::NoCarrier);
|
|
}
|
|
if text.contains("ERROR") {
|
|
return Err(FaxError::modem("Dial command failed"));
|
|
}
|
|
if text.contains("+FHNG:") {
|
|
return Err(FaxError::protocol(format!("Fax hangup: {}", text)));
|
|
}
|
|
|
|
// Connection established
|
|
if text.contains("CONNECT") {
|
|
tracing::info!("Connected!");
|
|
// Read remaining session info (e.g. +FCON)
|
|
let extra = self.driver.read_all(2000)?;
|
|
let extra_text = String::from_utf8_lossy(&extra);
|
|
if !extra_text.trim().is_empty() {
|
|
tracing::info!("Session info: {}", extra_text);
|
|
if extra_text.contains("+FHNG:") {
|
|
return Err(FaxError::protocol(format!("Fax hangup: {}", extra_text.trim())));
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Some modems send +FCON without CONNECT
|
|
if text.contains("+FCON") {
|
|
tracing::info!("Fax conference established");
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
Err(FaxError::Timeout { ms: 60000 })
|
|
}
|
|
|
|
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 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(e) => return Err(e),
|
|
};
|
|
let text = String::from_utf8_lossy(&response);
|
|
tracing::info!("FDT response: {}", text.trim());
|
|
|
|
if text.contains("CONNECT") {
|
|
got_connect = true;
|
|
break;
|
|
}
|
|
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: 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
|
|
tracing::info!("Encoding {}x{} pixels to T.4 MH...", page.width_pels, page.rows);
|
|
let mh_data = MhEncoder::encode_page(page)?;
|
|
tracing::info!("Encoded to {} bytes", mh_data.len());
|
|
|
|
// Escape any DLE (0x10) bytes in the data by doubling them
|
|
let escaped_data = self.escape_dle(&mh_data);
|
|
|
|
self.driver.write_raw(&escaped_data)?;
|
|
self.driver.write_raw(&DLE_ETX)?;
|
|
self.driver.flush()?;
|
|
|
|
// 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);
|
|
|
|
// Check for errors
|
|
if result_text.contains("ERROR") {
|
|
return Err(FaxError::protocol("Page transmission failed"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn end_page(&mut self, last_page: bool) -> Result<()> {
|
|
let cmd = Class2Commands::end_page(!last_page);
|
|
self.driver.write_raw(cmd.as_bytes())?;
|
|
self.driver.write_raw(b"\r")?;
|
|
self.driver.flush()?;
|
|
|
|
// 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)));
|
|
}
|
|
}
|
|
|
|
// Allow modem time to prepare for next page
|
|
std::thread::sleep(std::time::Duration::from_millis(1500));
|
|
self.driver.drain()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn hang_up(&mut self) -> Result<()> {
|
|
self.driver.write_raw(b"AT+FHNG=0\r")?;
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
self.driver.drain()?;
|
|
|
|
// Also send ATH to ensure hangup
|
|
self.driver.write_raw(b"ATH\r")?;
|
|
self.driver.drain()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Escape DLE characters in fax data (Class 2 requires DLE doubling)
|
|
fn escape_dle(&self, data: &[u8]) -> Vec<u8> {
|
|
let mut escaped = Vec::with_capacity(data.len() * 2);
|
|
for &byte in data {
|
|
if byte == DLE {
|
|
escaped.push(DLE);
|
|
escaped.push(DLE); // Double DLE to escape it
|
|
} else {
|
|
escaped.push(byte);
|
|
}
|
|
}
|
|
escaped
|
|
}
|
|
} |