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 { 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 } }