Initial commit: telfax - Rust Fax Server

- Modem driver (serialport) with AT command interface
- Class 1 fax protocol (T.30 session, HDLC framing)
- T.4/T.6 image codec (fax crate integration)
- Document conversion (image to fax, TIFF-F output)
- REST API (axum) for fax job management
- SQLite/InMemory job queue
- CLI interface (detect/serve/send/test)
- Integration tests passing
This commit is contained in:
Warren
2026-07-01 09:06:41 +08:00
commit 13438289fe
28 changed files with 4615 additions and 0 deletions

27
src/fax/class1/mod.rs Normal file
View File

@@ -0,0 +1,27 @@
pub mod send;
pub mod recv;
pub mod session;
pub use send::Class1Send;
pub use recv::Class1Recv;
pub use session::T30Session;
use crate::modem::driver::ModemDriver;
pub struct Class1Session<'a> {
driver: &'a mut ModemDriver,
}
impl<'a> Class1Session<'a> {
pub fn new(driver: &'a mut ModemDriver) -> Self {
Self { driver }
}
pub fn sender(self) -> Class1Send<'a> {
Class1Send::new(self.driver)
}
pub fn receiver(self) -> Class1Recv<'a> {
Class1Recv::new(self.driver)
}
}

35
src/fax/class1/recv.rs Normal file
View File

@@ -0,0 +1,35 @@
use crate::error::{FaxError, Result};
use crate::fax::class1::session::{T30Event, T30Session};
use crate::modem::driver::ModemDriver;
pub struct Class1Recv<'a> {
driver: &'a mut ModemDriver,
session: T30Session,
}
impl<'a> Class1Recv<'a> {
pub fn new(driver: &'a mut ModemDriver) -> Self {
Self {
driver,
session: T30Session::new(),
}
}
pub fn receive_fax(&mut self) -> Result<Vec<u8>> {
let mut at = crate::modem::at::AtChannel::new(self.driver);
at.send_command("AT+FCLASS=1", 2000)?;
at.send_command("ATS0=1", 2000)?;
tracing::info!("Waiting for incoming call...");
let _ring = self.driver.read_until(b"RING\r\n", 120_000)?;
tracing::info!("RING received");
let _conn = self.driver.read_until(b"CONNECT\r\n", 60000)?;
tracing::info!("Incoming fax connected");
self.session.transition(T30Event::Connected);
Err(FaxError::protocol("Receive not yet fully implemented"))
}
}

219
src/fax/class1/send.rs Normal file
View File

@@ -0,0 +1,219 @@
use crate::error::{FaxError, Result};
use crate::fax::class1::session::{T30Event, T30Session};
use crate::fax::hdlc::{build_hdlc_frame, parse_hdlc_frame};
use crate::fax::negotiate::{DataRate, FaxCapabilities};
use crate::modem::driver::ModemDriver;
pub struct Class1Send<'a> {
driver: &'a mut ModemDriver,
session: T30Session,
#[allow(dead_code)]
caps: FaxCapabilities,
data_rate: DataRate,
}
impl<'a> Class1Send<'a> {
pub fn new(driver: &'a mut ModemDriver) -> Self {
Self {
driver,
session: T30Session::new(),
caps: FaxCapabilities::default(),
data_rate: DataRate::V17_14400,
}
}
fn at(&mut self) -> crate::modem::at::AtChannel<'_> {
crate::modem::at::AtChannel::new(self.driver)
}
pub fn send_fax(
&mut self,
number: &str,
page_data: &[u8],
_page_width: u32,
_page_rows: u32,
) -> Result<()> {
self.enter_class1()?;
self.set_speaker_volume()?;
self.dial(number)?;
self.session.transition(T30Event::Connected);
let dis_frame = self.receive_hdlc_frame()?;
tracing::info!("Received DIS frame ({} bytes)", dis_frame.len());
let parsed_dis = parse_hdlc_frame(&dis_frame)?;
tracing::info!(control = parsed_dis.control, "Parsed DIS");
self.negotiate_caps(&parsed_dis)?;
let dcs_data = self.build_dcs();
let dcs_frame = build_hdlc_frame(0x28, &dcs_data);
self.transmit_hdlc_frame(&dcs_frame)?;
tracing::info!("Sent DCS");
let rate_val = self.data_rate.to_modulation_value();
self.transmit_tcf(rate_val)?;
tracing::info!("Sent TCF");
let cfr_frame = self.receive_hdlc_frame()?;
let parsed_cfr = parse_hdlc_frame(&cfr_frame)?;
if parsed_cfr.control != 0x21 {
return Err(FaxError::protocol("Expected CFR frame"));
}
tracing::info!("Received CFR");
self.session.transition(T30Event::CfrReceived);
self.transmit_page_data(rate_val, page_data)?;
self.session.transition(T30Event::PageSent);
self.send_eop()?;
let mcf_frame = self.receive_hdlc_frame()?;
let parsed_mcf = parse_hdlc_frame(&mcf_frame)?;
tracing::info!(control = parsed_mcf.control, "Post-page response received");
self.session.transition(T30Event::McfReceived);
self.send_dcn()?;
self.hang_up()?;
self.session.transition(T30Event::DcnSent);
Ok(())
}
fn enter_class1(&mut self) -> Result<()> {
self.at().send_command("AT+FCLASS=1", 2000)?;
Ok(())
}
fn set_speaker_volume(&mut self) -> Result<()> {
self.at().send_command("ATM1L2", 1000).ok();
Ok(())
}
fn dial(&mut self, number: &str) -> Result<()> {
self.driver.drain()?;
let cmd = format!("ATDT{}\r", number);
self.driver.write_raw(cmd.as_bytes())?;
self.driver.flush()?;
let response = self.driver.read_until(b"\r\n", 60000)?;
let text = String::from_utf8_lossy(&response);
tracing::info!(response = %text, "Dial response");
if text.contains("NO CARRIER") || text.contains("BUSY") {
return Err(FaxError::NoCarrier);
}
if !text.contains("CONNECT") {
return Err(FaxError::modem(format!("Unexpected dial response: {}", text)));
}
Ok(())
}
fn receive_hdlc_frame(&mut self) -> Result<Vec<u8>> {
self.driver.write_raw(b"AT+FRH=3\r")?;
let response = self.driver.read_until(b"\r\n", 15000)?;
let text = String::from_utf8_lossy(&response);
tracing::debug!(response = %text, "FRH initial");
if text.contains("NO CARRIER") || text.contains("ERROR") {
return Err(FaxError::NoCarrier);
}
let result = self.driver.read_until(b"OK\r\n", 10000)?;
Ok(result)
}
fn transmit_hdlc_frame(&mut self, frame: &[u8]) -> Result<()> {
self.driver.write_raw(b"AT+FTH=3\r")?;
let response = self.driver.read_until(b"CONNECT\r\n", 10000)?;
let text = String::from_utf8_lossy(&response);
if text.contains("ERROR") {
return Err(FaxError::modem("Failed to start HDLC transmit"));
}
self.driver.write_raw(frame)?;
self.driver.flush()?;
let result = self.driver.read_until(b"OK\r\n", 5000)?;
let result_text = String::from_utf8_lossy(&result);
if result_text.contains("ERROR") {
return Err(FaxError::modem("HDLC transmit failed"));
}
Ok(())
}
fn transmit_tcf(&mut self, rate_val: u32) -> Result<()> {
let cmd = format!("AT+FTM={}\r", rate_val);
self.driver.write_raw(cmd.as_bytes())?;
let response = self.driver.read_until(b"CONNECT\r\n", 10000)?;
let text = String::from_utf8_lossy(&response);
if text.contains("ERROR") {
return Err(FaxError::modem("Failed to start TCF transmit"));
}
let tcf_data = vec![0u8; 2000];
self.driver.write_raw(&tcf_data)?;
self.driver.flush()?;
let result = self.driver.read_until(b"OK\r\n", 10000)?;
let result_text = String::from_utf8_lossy(&result);
if result_text.contains("ERROR") {
return Err(FaxError::TrainingFailed);
}
Ok(())
}
fn transmit_page_data(&mut self, rate_val: u32, data: &[u8]) -> Result<()> {
let cmd = format!("AT+FTM={}\r", rate_val);
self.driver.write_raw(cmd.as_bytes())?;
let response = self.driver.read_until(b"CONNECT\r\n", 10000)?;
let text = String::from_utf8_lossy(&response);
if text.contains("ERROR") {
return Err(FaxError::modem("Failed to start page data transmit"));
}
self.driver.write_raw(data)?;
self.driver.write_raw(b"\x01\x01\x01\x01")?;
self.driver.flush()?;
let result = self.driver.read_until(b"OK\r\n", 15000)?;
let result_text = String::from_utf8_lossy(&result);
if result_text.contains("ERROR") {
return Err(FaxError::protocol("Page transmit failed"));
}
Ok(())
}
fn send_eop(&mut self) -> Result<()> {
let eop_data = build_hdlc_frame(0x42, &[]);
self.transmit_hdlc_frame(&eop_data)
}
fn send_dcn(&mut self) -> Result<()> {
let dcn_data = build_hdlc_frame(0x62, &[]);
self.transmit_hdlc_frame(&dcn_data)
}
fn hang_up(&mut self) -> Result<()> {
self.driver.write_raw(b"ATH\r")?;
std::thread::sleep(std::time::Duration::from_millis(500));
self.driver.drain()?;
Ok(())
}
fn negotiate_caps(&mut self, _dis_frame: &crate::fax::hdlc::HdlcFrame) -> Result<()> {
self.data_rate = DataRate::V17_14400;
Ok(())
}
fn build_dcs(&self) -> Vec<u8> {
let mut dcs = Vec::new();
dcs.push(0x00);
dcs.push(0x02);
dcs.push(0x05);
dcs
}
}

103
src/fax/class1/session.rs Normal file
View File

@@ -0,0 +1,103 @@
/// T.30 session state machine for Group 3 fax.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum T30State {
Idle,
Dialing,
HandshakeTransmitting, // Phase B - sending DIS/DCS
HandshakeReceiving, // Phase B - receiving DIS/DCS
DcsSent, // DCS has been sent, waiting for CFR
CfrReceived, // Received CFR, ready to send pages
PageTransmitting, // Phase C - sending page data
PageReceiving,
PostPageHandshake, // Phase D - MCF, EOP, MPS
Disconnecting, // Phase E - DCN
Completed,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum T30Event {
Connected,
DisReceived,
DisSent,
DcsReceived,
DcsSent,
TcfSent,
TcfReceived,
CfrReceived,
CfrSent,
PageSent,
PageReceived,
McfReceived,
McfSent,
EopReceived,
EopSent,
DcnReceived,
DcnSent,
Timeout,
Error,
Retrain,
}
pub struct T30Session {
state: T30State,
retry_count: u8,
max_retries: u8,
}
impl T30Session {
pub fn new() -> Self {
Self {
state: T30State::Idle,
retry_count: 0,
max_retries: 3,
}
}
pub fn state(&self) -> T30State {
self.state
}
pub fn start_receive(&mut self) {
self.state = T30State::HandshakeReceiving;
}
pub fn transition(&mut self, event: T30Event) {
match (self.state, event) {
// Send flow
(T30State::Idle, T30Event::Connected) => {
// Default to transmitting; caller can override with start_receive()
self.state = T30State::HandshakeTransmitting;
}
(T30State::HandshakeTransmitting, T30Event::DisReceived) => {
self.state = T30State::DcsSent;
}
(T30State::DcsSent, T30Event::CfrReceived) => self.state = T30State::PageTransmitting,
(T30State::PageTransmitting, T30Event::PageSent) => self.state = T30State::PostPageHandshake,
(T30State::PostPageHandshake, T30Event::McfReceived) => self.state = T30State::Disconnecting,
(T30State::PostPageHandshake, T30Event::EopReceived) => self.state = T30State::Disconnecting,
(T30State::Disconnecting, T30Event::DcnSent) => self.state = T30State::Completed,
// Receive flow
(T30State::HandshakeReceiving, T30Event::DcsReceived) => self.state = T30State::PageReceiving,
(T30State::PageReceiving, T30Event::PageReceived) => self.state = T30State::PostPageHandshake,
(T30State::PostPageHandshake, T30Event::McfSent) => self.state = T30State::Disconnecting,
(T30State::Disconnecting, T30Event::DcnReceived) => self.state = T30State::Completed,
// Error handling
(_, T30Event::Timeout) | (_, T30Event::Error) => {
if self.retry_count < self.max_retries {
self.retry_count += 1;
// Stay in same state for retry
} else {
self.state = T30State::Error;
}
}
_ => {
// Unexpected transition - log and set error
self.state = T30State::Error;
}
}
}
}

150
src/fax/hdlc.rs Normal file
View File

@@ -0,0 +1,150 @@
use crate::error::{FaxError, Result};
const HDLC_FLAG: u8 = 0x7E;
const HDLC_ESC: u8 = 0x7D;
/// T.30 HDLC frame types used in fax protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameType {
/// Digital Identification Signal (DIS) - Called station capabilities
Dis,
/// Digital Command Signal (DCS) - Transmitting station capabilities
Dcs,
/// Training Check Frame
Tcf,
/// Confirmation to Receive (CFR) - Ready for pages
Cfr,
/// Frame Check Sequence error / invalid
Invalid,
/// Unknown frame type
Other(u8),
}
impl FrameType {
pub fn from_control(ctrl: u8) -> Self {
match ctrl {
0x08 => FrameType::Dis,
0x28 => FrameType::Dcs,
0x41 => FrameType::Tcf,
0x21 => FrameType::Cfr,
_ => FrameType::Other(ctrl),
}
}
}
#[derive(Debug, Clone)]
pub struct HdlcFrame {
pub address: u8,
pub control: u8,
pub information: Vec<u8>,
pub fcs: u16,
}
/// Parse HDLC frames from a byte stream.
pub fn parse_hdlc_frame(data: &[u8]) -> Result<HdlcFrame> {
// Remove leading flags
let start = data.iter().position(|&b| b != HDLC_FLAG)
.ok_or_else(|| FaxError::Hdlc("No HDLC frame found".into()))?;
let mut frame_data = Vec::new();
let mut i = start;
let mut escaped = false;
while i < data.len() {
let byte = data[i];
if byte == HDLC_FLAG {
if !frame_data.is_empty() {
break;
}
i += 1;
continue;
}
if byte == HDLC_ESC {
escaped = true;
i += 1;
continue;
}
if escaped {
frame_data.push(byte ^ 0x20);
escaped = false;
} else {
frame_data.push(byte);
}
i += 1;
}
if frame_data.len() < 4 {
return Err(FaxError::Hdlc(format!(
"Frame too short: {} bytes",
frame_data.len()
)));
}
let fcs_bytes = &frame_data[frame_data.len() - 2..];
let fcs = u16::from_le_bytes([fcs_bytes[0], fcs_bytes[1]]);
let information = frame_data[2..frame_data.len() - 2].to_vec();
Ok(HdlcFrame {
address: frame_data[0],
control: frame_data[1],
information,
fcs,
})
}
/// Build HDLC frame bytes for a T.30 fax frame.
pub fn build_hdlc_frame(control: u8, information: &[u8]) -> Vec<u8> {
let mut frame = Vec::new();
frame.push(0xFF); // address (broadcast)
frame.push(control); // control field
frame.extend_from_slice(information);
let fcs = compute_fcs(&frame);
frame.extend_from_slice(&fcs.to_le_bytes());
// Bit-stuff and wrap in flags
let mut stuffed = Vec::new();
stuffed.push(HDLC_FLAG);
for &byte in &frame {
if byte == HDLC_FLAG || byte == HDLC_ESC {
stuffed.push(HDLC_ESC);
stuffed.push(byte ^ 0x20);
} else {
stuffed.push(byte);
}
}
stuffed.push(HDLC_FLAG);
stuffed
}
/// Compute CRC-CCITT (16-bit FCS) for HDLC frames.
fn compute_fcs(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &byte in data {
crc ^= (byte as u16) << 8;
for _ in 0..8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
!crc
}
/// Check if an HDLC frame has a valid FCS.
pub fn verify_fcs(frame: &[u8]) -> bool {
let mut crc: u16 = 0xFFFF;
for &byte in frame {
crc ^= (byte as u16) << 8;
for _ in 0..8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
crc == 0x1D0F
}

7
src/fax/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod t4;
pub mod hdlc;
pub mod negotiate;
pub mod class1;
pub use class1::Class1Session;
pub use t4::T4Codec;

91
src/fax/negotiate.rs Normal file
View File

@@ -0,0 +1,91 @@
#[derive(Debug, Clone)]
pub struct FaxCapabilities {
pub resolution: Resolution,
pub page_width: PageWidth,
pub page_length: PageLength,
pub data_rate: DataRate,
pub ecm: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
Standard, // 98 lpi, 204 dpi
Fine, // 196 lpi, 204 dpi
SuperFine, // 392 lpi, 204 dpi
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageWidth {
A4, // 1728 pels
A3, // 2432 pels
B4, // 2048 pels
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageLength {
A4, // 297mm
Unlimited,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataRate {
V27ter2400,
V27ter4800,
V29_7200,
V29_9600,
V17_7200,
V17_9600,
V17_12000,
V17_14400,
}
impl DataRate {
pub fn to_modulation_value(&self) -> u32 {
match self {
DataRate::V27ter2400 => 24,
DataRate::V27ter4800 => 48,
DataRate::V29_7200 => 72,
DataRate::V29_9600 => 96,
DataRate::V17_7200 => 73,
DataRate::V17_9600 => 97,
DataRate::V17_12000 => 121,
DataRate::V17_14400 => 145,
}
}
pub fn from_modulation_value(val: u32) -> Option<Self> {
match val {
24 => Some(DataRate::V27ter2400),
48 => Some(DataRate::V27ter4800),
72 => Some(DataRate::V29_7200),
96 => Some(DataRate::V29_9600),
73 => Some(DataRate::V17_7200),
97 => Some(DataRate::V17_9600),
121 => Some(DataRate::V17_12000),
145 => Some(DataRate::V17_14400),
_ => None,
}
}
pub fn max_for_modem(supported_rates: &[u32]) -> Option<Self> {
let priority = [145, 121, 97, 73, 96, 72, 48, 24];
for rate in &priority {
if supported_rates.contains(rate) {
return Self::from_modulation_value(*rate);
}
}
None
}
}
impl Default for FaxCapabilities {
fn default() -> Self {
Self {
resolution: Resolution::Fine,
page_width: PageWidth::A4,
page_length: PageLength::A4,
data_rate: DataRate::V17_14400,
ecm: true,
}
}
}

106
src/fax/t4.rs Normal file
View File

@@ -0,0 +1,106 @@
use crate::error::{FaxError, Result};
pub struct T4Codec;
#[derive(Debug, Clone)]
pub struct T4Page {
pub data: Vec<u8>,
pub width_pels: u32,
pub rows: u32,
pub encoding: T4Encoding,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum T4Encoding {
Group3MH,
Group3MR,
Group4MMR,
}
impl T4Codec {
/// Decode T.4/T.6 data into raw 1-bit pixel data (MSB-first, row-major).
/// `transitions_to_pixels` converts run-length transitions to pixel data.
fn transitions_to_pixels(
transitions: &[u16],
width_pels: u32,
row_buf: &mut [u8],
) {
row_buf.fill(0);
let mut is_black = false;
let mut prev = 0u32;
for &t in transitions {
let t = t as u32;
if t > width_pels {
break;
}
if is_black {
for p in prev..t.min(width_pels) {
let byte_idx = (p / 8) as usize;
let bit_idx = 7 - (p % 8);
if byte_idx < row_buf.len() {
row_buf[byte_idx] |= 1 << bit_idx;
}
}
}
prev = t;
is_black = !is_black;
}
// If ends on black, fill to end
if is_black {
for p in prev..width_pels {
let byte_idx = (p / 8) as usize;
let bit_idx = 7 - (p % 8);
if byte_idx < row_buf.len() {
row_buf[byte_idx] |= 1 << bit_idx;
}
}
}
}
pub fn decode(data: &[u8], width_pels: u32, rows: u32, encoding: T4Encoding) -> Result<Vec<u8>> {
let bytes_per_row = ((width_pels + 7) / 8) as usize;
let mut pixels = vec![0u8; bytes_per_row * rows as usize];
let data: Vec<u8> = data.to_vec();
match encoding {
T4Encoding::Group3MH | T4Encoding::Group3MR => {
let mut row_idx = 0u32;
let result = fax::decoder::decode_g3(data.into_iter(), |transitions: &[u16]| {
if (row_idx as usize) < pixels.len() / bytes_per_row {
let start = row_idx as usize * bytes_per_row;
let end = (start + bytes_per_row).min(pixels.len());
let row_buf = &mut pixels[start..end];
Self::transitions_to_pixels(transitions, width_pels, row_buf);
}
row_idx += 1;
});
match result {
Some(()) => Ok(pixels),
None => Err(FaxError::t4("Group 3 decode returned None")),
}
}
T4Encoding::Group4MMR => {
let mut row_idx = 0u32;
let result = fax::decoder::decode_g4(
data.into_iter(),
width_pels as u16,
None,
|transitions: &[u16]| {
if (row_idx as usize) < pixels.len() / bytes_per_row {
let start = row_idx as usize * bytes_per_row;
let end = (start + bytes_per_row).min(pixels.len());
let row_buf = &mut pixels[start..end];
Self::transitions_to_pixels(transitions, width_pels, row_buf);
}
row_idx += 1;
},
);
match result {
Some(()) => Ok(pixels),
None => Err(FaxError::t4("Group 4 decode returned None")),
}
}
}
}
}