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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/target
*.swp
*.swo
*~
.DS_Store
*.log
.env
data/

2642
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

63
Cargo.toml Normal file
View File

@@ -0,0 +1,63 @@
[package]
name = "telfax"
version = "0.1.0"
edition = "2024"
description = "A standalone fax server library and application"
license = "MIT"
[features]
default = ["server"]
server = ["dep:axum", "dep:clap", "dep:tokio", "dep:rusqlite", "dep:tower-http", "dep:tracing-subscriber"]
cli = ["dep:clap"]
[lib]
name = "telfax"
[[bin]]
name = "telfax"
path = "src/main.rs"
required-features = ["server"]
[dependencies]
# Serial modem
serialport = "4.3"
# Fax codec (T.4/T.6 compression)
fax = "0.2"
# Document handling
image = "0.25"
tiff = "0.11"
lopdf = "0.36"
# Error handling
thiserror = "2"
anyhow = "1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Logging
tracing = "0.1"
# Async (for server mode)
tokio = { version = "1", features = ["full"], optional = true }
axum = { version = "0.8", optional = true }
tower-http = { version = "0.6", features = ["cors"], optional = true }
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"], optional = true }
# CLI / Config
clap = { version = "4", features = ["derive"], optional = true }
# Queue persistence
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
# UUID for job IDs
uuid = { version = "1", features = ["v4", "serde"] }
# Date/time
chrono = { version = "0.4", features = ["serde"] }
# Config file parsing
toml = "0.8"

3
src/api/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod routes;
pub use routes::start_server;

128
src/api/routes.rs Normal file
View File

@@ -0,0 +1,128 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tracing::info;
use crate::config::FaxConfig;
use crate::error::Result as FaxResult;
use crate::queue::job::{FaxJob, JobId};
use crate::queue::store::FaxQueue;
#[derive(Clone)]
pub struct AppState {
pub config: FaxConfig,
pub queue: Arc<Mutex<FaxQueue>>,
}
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub device: String,
}
#[derive(Deserialize)]
pub struct SendFaxRequest {
pub recipient: String,
pub document_path: String,
}
#[derive(Serialize)]
pub struct SendFaxResponse {
pub job_id: JobId,
}
#[derive(Serialize)]
pub struct JobResponse {
pub id: JobId,
pub recipient: String,
pub status: String,
pub pages: u32,
pub created_at: String,
}
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
device: state.config.device.clone(),
})
}
async fn send_fax(
State(state): State<AppState>,
Json(req): Json<SendFaxRequest>,
) -> Result<Json<SendFaxResponse>, StatusCode> {
let job = FaxJob::new(req.recipient, req.document_path);
let job_id = job.id;
let mut queue = state.queue.lock().unwrap();
queue.enqueue(job).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
info!(job_id = %job_id, "Fax job queued");
Ok(Json(SendFaxResponse { job_id }))
}
async fn list_jobs(State(state): State<AppState>) -> Json<Vec<JobResponse>> {
let queue = state.queue.lock().unwrap();
let jobs = queue.list();
let responses: Vec<JobResponse> = jobs
.into_iter()
.map(|j| JobResponse {
id: j.id,
recipient: j.recipient,
status: format!("{:?}", j.status),
pages: j.pages,
created_at: j.created_at.to_rfc3339(),
})
.collect();
Json(responses)
}
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();
queue.get(&id).map(|j| Json(JobResponse {
id: j.id,
recipient: j.recipient,
status: format!("{:?}", j.status),
pages: j.pages,
created_at: j.created_at.to_rfc3339(),
})).ok_or(StatusCode::NOT_FOUND)
}
pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> FaxResult<()> {
let state = AppState {
config: config.clone(),
queue,
};
let app = 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))
.with_state(state);
let addr = config
.api_listen
.clone()
.unwrap_or_else(|| "0.0.0.0:3000".to_string());
info!("Starting API server on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| crate::error::FaxError::Other(format!("Failed to bind: {}", e)))?;
axum::serve(listener, app)
.await
.map_err(|e| crate::error::FaxError::Other(format!("Server error: {}", e)))?;
Ok(())
}

97
src/config.rs Normal file
View File

@@ -0,0 +1,97 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaxConfig {
#[serde(default = "default_device")]
pub device: String,
#[serde(default = "default_baud_rate")]
pub baud_rate: u32,
#[serde(default = "default_station_id")]
pub station_id: String,
#[serde(default = "default_header")]
pub header: String,
#[serde(default = "default_resolution")]
pub resolution: FaxResolution,
#[serde(default = "default_ecm")]
pub ecm: bool,
#[serde(default = "default_volume")]
pub speaker_volume: u8,
#[serde(default)]
pub queue_db: Option<PathBuf>,
#[serde(default)]
pub api_listen: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FaxResolution {
Standard, // 98 dpi
Fine, // 196 dpi (7.7 l/mm)
SuperFine, // 392 dpi (15.4 l/mm)
}
impl Default for FaxConfig {
fn default() -> Self {
Self {
device: default_device(),
baud_rate: default_baud_rate(),
station_id: default_station_id(),
header: default_header(),
resolution: default_resolution(),
ecm: default_ecm(),
speaker_volume: default_volume(),
queue_db: None,
api_listen: None,
}
}
}
fn default_device() -> String {
"/dev/cu.usbmodem00000021".to_string()
}
fn default_baud_rate() -> u32 {
115200
}
fn default_station_id() -> String {
"+1-555-FAX-0001".to_string()
}
fn default_header() -> String {
"Telfax".to_string()
}
fn default_resolution() -> FaxResolution {
FaxResolution::Fine
}
fn default_ecm() -> bool {
true
}
fn default_volume() -> u8 {
2
}
impl FaxResolution {
pub fn vertical_lpi(&self) -> u32 {
match self {
FaxResolution::Standard => 98,
FaxResolution::Fine => 196,
FaxResolution::SuperFine => 392,
}
}
pub fn horizontal_dpi(&self) -> u32 {
204
}
}

83
src/document/convert.rs Normal file
View File

@@ -0,0 +1,83 @@
use crate::error::{FaxError, Result};
use image::GenericImageView;
#[derive(Debug, Clone)]
pub enum DocumentFormat {
Pdf,
Tiff,
Image,
}
#[derive(Debug, Clone)]
pub struct Page {
pub pixels: Vec<u8>,
pub width_pels: u32,
pub rows: u32,
}
#[derive(Debug, Clone)]
pub struct FaxDocument {
pub pages: Vec<Page>,
pub format: DocumentFormat,
}
/// Create a FaxDocument from raw TIFF Group 3/4 data.
pub fn document_from_tiff(_data: &[u8]) -> Result<FaxDocument> {
Err(FaxError::document("TIFF to fax document not yet implemented"))
}
/// Create a FaxDocument from an image file (PNG, JPEG, etc.)
pub fn document_from_image(data: &[u8]) -> Result<FaxDocument> {
let img = image::load_from_memory(data)
.map_err(|e| FaxError::document(format!("Failed to load image: {}", e)))?;
let (width, height) = img.dimensions();
let gray = img.to_luma8();
let fax_width = if width > 1728 { 1728 } else { width };
let fax_rows = (height as f64 * (fax_width as f64 / width as f64)) as u32;
let bytes_per_row = ((fax_width + 7) / 8) as usize;
let mut pixels = vec![0u8; bytes_per_row * fax_rows as usize];
for y in 0..fax_rows {
let src_y = (y as f64 * height as f64 / fax_rows as f64) as u32;
let row_start = y as usize * bytes_per_row;
let mut bit_idx: u8 = 0;
let mut byte_val: u8 = 0;
let mut byte_pos: usize = 0;
for x in 0..fax_width {
let src_x = (x as f64 * width as f64 / fax_width as f64) as u32;
let pixel = gray.get_pixel(src_x, src_y)[0];
if pixel < 128 {
byte_val |= 1 << (7 - bit_idx);
}
bit_idx += 1;
if bit_idx >= 8 {
let idx = row_start + byte_pos;
if idx < pixels.len() {
pixels[idx] = byte_val;
}
byte_pos += 1;
bit_idx = 0;
byte_val = 0;
}
}
if bit_idx > 0 && row_start + byte_pos < pixels.len() {
pixels[row_start + byte_pos] = byte_val;
}
}
let page = Page {
pixels,
width_pels: fax_width,
rows: fax_rows,
};
Ok(FaxDocument {
pages: vec![page],
format: DocumentFormat::Image,
})
}

5
src/document/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod convert;
pub mod tiff;
pub use convert::{document_from_image, document_from_tiff, DocumentFormat, FaxDocument, Page};
pub use tiff::TiffFaxWriter;

35
src/document/tiff.rs Normal file
View File

@@ -0,0 +1,35 @@
use crate::document::convert::Page;
use crate::error::{FaxError, Result};
use tiff::encoder::colortype::Gray8;
use tiff::encoder::TiffEncoder;
pub struct TiffFaxWriter;
impl TiffFaxWriter {
pub fn write_fax_tiff(path: &std::path::Path, pages: &[Page]) -> Result<()> {
if pages.is_empty() {
return Err(FaxError::document("No pages to write"));
}
let file = std::fs::File::create(path)?;
let mut encoder = TiffEncoder::new(&file)
.map_err(|e| FaxError::document(format!("Failed to create TIFF encoder: {}", e)))?;
for page in pages {
// Convert 1-bit fax data to 8-bit grayscale for TIFF storage.
let mut gray_data = Vec::with_capacity((page.width_pels * page.rows) as usize);
for &byte in &page.pixels {
for bit_idx in (0..8).rev() {
let bit = (byte >> bit_idx) & 1;
gray_data.push(if bit == 1 { 0u8 } else { 255u8 });
}
}
encoder
.write_image::<Gray8>(page.width_pels, page.rows, &gray_data)
.map_err(|e| FaxError::document(format!("Failed to write TIFF page: {}", e)))?;
}
Ok(())
}
}

74
src/error.rs Normal file
View File

@@ -0,0 +1,74 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, FaxError>;
#[derive(Error, Debug)]
pub enum FaxError {
#[error("Serial port error: {0}")]
Serial(#[from] serialport::Error),
#[error("Modem error: {0}")]
Modem(String),
#[error("AT command failed: cmd={cmd}, response={response}")]
AtCommand { cmd: String, response: String },
#[error("Modem timeout after {ms}ms")]
Timeout { ms: u64 },
#[error("Protocol error: {0}")]
Protocol(String),
#[error("No carrier / connection lost")]
NoCarrier,
#[error("Remote station error (training failed)")]
TrainingFailed,
#[error("Fax document error: {0}")]
Document(String),
#[error("T.4 codec error: {0}")]
T4Codec(String),
#[error("HDLC framing error: {0}")]
Hdlc(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid configuration: {0}")]
Config(String),
#[error("Not supported by modem: {0}")]
NotSupported(String),
#[cfg(feature = "server")]
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("{0}")]
Other(String),
}
impl FaxError {
pub fn modem(msg: impl Into<String>) -> Self {
Self::Modem(msg.into())
}
pub fn protocol(msg: impl Into<String>) -> Self {
Self::Protocol(msg.into())
}
pub fn document(msg: impl Into<String>) -> Self {
Self::Document(msg.into())
}
pub fn t4(msg: impl Into<String>) -> Self {
Self::T4Codec(msg.into())
}
pub fn config(msg: impl Into<String>) -> Self {
Self::Config(msg.into())
}
}

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")),
}
}
}
}
}

31
src/lib.rs Normal file
View File

@@ -0,0 +1,31 @@
//! # telfax - A standalone fax server library
//!
//! `telfax` provides a complete fax solution supporting:
//! - Class 1 fax modem communication via serial port
//! - T.30 fax session management
//! - T.4/T.6 image encoding/decoding
//! - Document conversion (images, TIFF)
//! - REST API for fax job management (optional)
//! - Persistent job queue (optional)
pub mod config;
pub mod error;
pub mod modem;
pub mod fax;
pub mod document;
pub mod queue;
#[cfg(feature = "server")]
pub mod api;
pub use config::FaxConfig;
pub use error::{FaxError, Result};
pub use fax::Class1Session;
pub use fax::t4::T4Codec;
pub use fax::hdlc;
pub use fax::negotiate;
pub use modem::ModemDriver;
pub use modem::AtChannel;
pub use modem::detect;
pub use document::{FaxDocument, Page};
pub use queue::{FaxJob, FaxQueue, JobId, JobStatus};

161
src/main.rs Normal file
View File

@@ -0,0 +1,161 @@
use clap::{Parser, Subcommand};
use std::sync::{Arc, Mutex};
use tracing::info;
use telfax::config::FaxConfig;
use telfax::error::Result;
use telfax::modem::detect::detect_modem;
use telfax::modem::ModemDriver;
#[derive(Parser)]
#[command(name = "telfax", version, about = "Fax server application")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Detect modem capabilities
Detect {
/// Serial device path
#[arg(default_value = "/dev/cu.usbmodem00000021")]
device: String,
},
/// Start the fax server
Serve {
/// Path to config file
#[arg(short, long)]
config: Option<String>,
},
/// Send a fax
Send {
/// Recipient phone number
recipient: String,
/// Path to document file (PNG/JPG/TIFF)
file: String,
/// Serial device
#[arg(short, long, default_value = "/dev/cu.usbmodem00000021")]
device: String,
},
/// Interactively test the modem
Test {
/// Serial device path
#[arg(default_value = "/dev/cu.usbmodem00000021")]
device: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "telfax=info".into()),
)
.init();
let cli = Cli::parse();
match cli.command.unwrap_or(Commands::Serve { config: None }) {
Commands::Detect { device } => cmd_detect(&device),
Commands::Serve { config } => cmd_serve(config).await,
Commands::Send { recipient, file, device } => cmd_send(&device, &recipient, &file),
Commands::Test { device } => cmd_test(&device),
}
}
fn cmd_detect(device: &str) -> Result<()> {
info!("Opening modem on {}...", device);
let mut driver = ModemDriver::open(device, 115200)?;
let info = detect_modem(&mut driver)?;
println!("=== Modem Information ===");
println!("Manufacturer: {}", info.manufacturer);
println!("Model: {}", info.model);
println!("Revision: {}", info.revision);
println!("Class 1: {}", info.supports_class_1);
println!("Class 2: {}", info.supports_class_2);
println!("TX Rates: {:?}", info.tx_rates);
println!("RX Rates: {:?}", info.rx_rates);
Ok(())
}
async fn cmd_serve(config_path: Option<String>) -> Result<()> {
let config = if let Some(path) = config_path {
let content = std::fs::read_to_string(&path)
.map_err(|e| telfax::error::FaxError::config(format!("Failed to read config: {}", e)))?;
toml::from_str(&content)
.map_err(|e| telfax::error::FaxError::config(format!("Invalid config: {}", e)))?
} else {
FaxConfig::default()
};
info!("Starting telfax server with device: {}", config.device);
let queue = if let Some(db_path) = &config.queue_db {
Arc::new(Mutex::new(telfax::FaxQueue::new_sqlite(db_path)?))
} else {
Arc::new(Mutex::new(telfax::FaxQueue::new_in_memory()))
};
// Start API server
telfax::api::start_server(config, queue.clone()).await
}
fn cmd_send(device: &str, recipient: &str, file: &str) -> Result<()> {
info!("Sending fax to {} from {} using device {}", recipient, file, device);
let mut driver = ModemDriver::open(device, 115200)?;
let data = std::fs::read(file)
.map_err(|e| telfax::error::FaxError::Other(format!("Failed to read file: {}", e)))?;
let doc = telfax::document::document_from_image(&data)?;
let page = &doc.pages[0];
let mut sender = telfax::fax::class1::Class1Send::new(&mut driver);
sender.send_fax(recipient, &page.pixels, page.width_pels, page.rows)?;
info!("Fax sent successfully!");
Ok(())
}
fn cmd_test(device: &str) -> Result<()> {
use std::io::{self, Write};
println!("Opening modem on {} for interactive test...", device);
let mut driver = ModemDriver::open(device, 115200)?;
let info = detect_modem(&mut driver)?;
println!("\n=== Modem Info ===");
println!("Model: {}", info.model);
println!("Supports Class 1: {}", info.supports_class_1);
println!("TX Rates: {:?}", info.tx_rates);
println!("\nTesting AT commands...");
let mut at = telfax::modem::at::AtChannel::new(&mut driver);
let tests = [
"AT",
"AT+FCLASS=1",
"AT+FTM=96",
"AT+FRM=96",
"AT+FTM=145",
"AT+FRM=145",
];
for cmd in tests {
print!(" {} ... ", cmd);
io::stdout().flush().ok();
match at.send_command(cmd, 3000) {
Ok(resp) => {
let last = resp.trim().lines().last().unwrap_or("");
println!("OK ({})", last);
}
Err(e) => println!("ERROR: {}", e),
}
}
println!("\nDone.");
Ok(())
}

62
src/modem/at.rs Normal file
View File

@@ -0,0 +1,62 @@
use crate::error::{FaxError, Result};
use crate::modem::driver::ModemDriver;
pub struct AtChannel<'a> {
driver: &'a mut ModemDriver,
}
impl<'a> AtChannel<'a> {
pub fn new(driver: &'a mut ModemDriver) -> Self {
Self { driver }
}
pub fn send_command(&mut self, command: &str, timeout_ms: u64) -> Result<String> {
self.driver.drain()?;
let cmd_line = format!("{}\r", command);
self.driver.write_raw(cmd_line.as_bytes())?;
let response = self.driver.read_until(b"OK\r\n", timeout_ms)?;
let text = String::from_utf8_lossy(&response).to_string();
if text.contains("ERROR") {
Err(FaxError::AtCommand {
cmd: command.to_string(),
response: text,
})
} else {
Ok(text)
}
}
pub fn send_command_expect(&mut self, command: &str, expected: &str, timeout_ms: u64) -> Result<String> {
self.driver.drain()?;
let cmd_line = format!("{}\r", command);
self.driver.write_raw(cmd_line.as_bytes())?;
let response = self.driver.read_until(expected.as_bytes(), timeout_ms)?;
let text = String::from_utf8_lossy(&response).to_string();
if text.contains("ERROR") {
Err(FaxError::AtCommand {
cmd: command.to_string(),
response: text,
})
} else {
Ok(text)
}
}
pub fn write_data(&mut self, data: &[u8]) -> Result<()> {
self.driver.write_raw(data)
}
pub fn read_data(&mut self, timeout_ms: u64) -> Result<Vec<u8>> {
self.driver.read_all(timeout_ms)
}
pub fn read_until_pattern(&mut self, pattern: &[u8], timeout_ms: u64) -> Result<Vec<u8>> {
self.driver.read_until(pattern, timeout_ms)
}
pub fn set_timeout(&mut self, timeout_ms: u64) -> Result<()> {
self.driver.set_timeout(timeout_ms)
}
}

110
src/modem/detect.rs Normal file
View File

@@ -0,0 +1,110 @@
use crate::error::Result;
use crate::modem::at::AtChannel;
use crate::modem::driver::ModemDriver;
#[derive(Debug, Clone)]
pub struct ModemInfo {
pub manufacturer: String,
pub model: String,
pub revision: String,
pub supports_class_1: bool,
pub supports_class_2: bool,
pub tx_rates: Vec<u32>,
pub rx_rates: Vec<u32>,
}
pub fn detect_modem(driver: &mut ModemDriver) -> Result<ModemInfo> {
let mut at = AtChannel::new(driver);
let manufacturer = at.send_command("AT+GMI", 3000).ok()
.and_then(|r| extract_value(&r, "+GMI:"))
.unwrap_or_default();
let model = at.send_command("AT+GMM", 3000).ok()
.and_then(|r| extract_value(&r, "+GMM:"))
.unwrap_or_default();
let model_fallback = at.send_command("ATI3", 3000).ok()
.and_then(|r| extract_ati_line(&r, 3))
.unwrap_or_default();
let revision = at.send_command("AT+GMR", 3000).ok()
.and_then(|r| extract_value(&r, "+GMR:"))
.unwrap_or_default();
let class_support = at.send_command("AT+FCLASS=?", 3000)?;
let supports_class_1 = class_support.contains(",1") || class_support.contains("1,");
let supports_class_2 = class_support.contains(",2") || class_support.starts_with("2,");
if supports_class_1 {
at.send_command("AT+FCLASS=1", 2000)?;
}
let tx_rates = if supports_class_1 {
at.send_command("AT+FTM=?", 3000)
.ok()
.and_then(|r| parse_rate_list(&r))
.unwrap_or_default()
} else {
Vec::new()
};
let rx_rates = if supports_class_1 {
at.send_command("AT+FRM=?", 3000)
.ok()
.and_then(|r| parse_rate_list(&r))
.unwrap_or_default()
} else {
Vec::new()
};
let model_name = if !model.is_empty() { model } else { model_fallback };
Ok(ModemInfo {
manufacturer,
model: model_name,
revision,
supports_class_1,
supports_class_2,
tx_rates,
rx_rates,
})
}
fn extract_value(response: &str, prefix: &str) -> Option<String> {
for line in response.lines() {
let trimmed = line.trim();
if let Some(val) = trimmed.strip_prefix(prefix) {
return Some(val.trim().to_string());
}
}
None
}
fn extract_ati_line(response: &str, _line_no: u8) -> Option<String> {
for line in response.lines() {
let trimmed = line.trim();
if !trimmed.is_empty()
&& !trimmed.starts_with("OK")
&& !trimmed.starts_with("ATI")
&& !trimmed.starts_with("--")
&& !trimmed.contains("Cmd")
{
return Some(trimmed.to_string());
}
}
None
}
fn parse_rate_list(response: &str) -> Option<Vec<u32>> {
let list_start = response.find(|c: char| c.is_ascii_digit() || c == '(')?;
let list_end = response[list_start..].find("\r\n").map(|i| list_start + i)
.unwrap_or(response.len());
let segment = &response[list_start..list_end];
let cleaned: String = segment.chars().filter(|c| c.is_ascii_digit() || *c == ',').collect();
let rates: Vec<u32> = cleaned
.split(',')
.filter_map(|s| s.parse().ok())
.collect();
if rates.is_empty() { None } else { Some(rates) }
}

137
src/modem/driver.rs Normal file
View File

@@ -0,0 +1,137 @@
use serialport::SerialPort;
use std::io::{Read, Write};
use std::time::Duration;
use crate::error::{FaxError, Result};
pub struct ModemDriver {
port: Box<dyn SerialPort>,
device: String,
baud_rate: u32,
}
impl ModemDriver {
pub fn open(device: &str, baud_rate: u32) -> Result<Self> {
let port = serialport::new(device, baud_rate)
.data_bits(serialport::DataBits::Eight)
.flow_control(serialport::FlowControl::None)
.parity(serialport::Parity::None)
.stop_bits(serialport::StopBits::One)
.timeout(Duration::from_secs(10))
.open()
.map_err(|e| FaxError::Serial(serialport::Error::new(
serialport::ErrorKind::Io(std::io::ErrorKind::Other),
format!("Failed to open {}: {}", device, e),
)))?;
// Let the modem initialize
std::thread::sleep(Duration::from_millis(500));
Ok(Self {
port,
device: device.to_string(),
baud_rate,
})
}
pub fn write_raw(&mut self, data: &[u8]) -> Result<()> {
self.port.write_all(data)?;
self.port.flush()?;
Ok(())
}
pub fn read_raw(&mut self, buf: &mut [u8]) -> Result<usize> {
self.port.read(buf).map_err(Into::into)
}
pub fn read_exact(&mut self, buf: &mut [u8]) -> Result<()> {
self.port.read_exact(buf).map_err(Into::into)
}
pub fn read_until(&mut self, pattern: &[u8], timeout_ms: u64) -> Result<Vec<u8>> {
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
let mut buffer = Vec::new();
let mut single = [0u8; 1];
while std::time::Instant::now() < deadline {
match self.port.read(&mut single) {
Ok(0) => continue,
Ok(_) => {
buffer.push(single[0]);
if buffer.ends_with(pattern) {
return Ok(buffer);
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
continue;
}
Err(e) => return Err(FaxError::Io(e)),
}
}
Err(FaxError::Timeout { ms: timeout_ms })
}
pub fn read_all(&mut self, timeout_ms: u64) -> Result<Vec<u8>> {
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
let mut buffer = Vec::new();
let mut single = [0u8; 1];
loop {
match self.port.read(&mut single) {
Ok(0) => break,
Ok(_) => {
buffer.push(single[0]);
}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
if deadline < std::time::Instant::now() || buffer.is_empty() {
break;
}
// if we have data and get a timeout, keep reading briefly
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(FaxError::Io(e)),
}
}
Ok(buffer)
}
pub fn flush(&mut self) -> Result<()> {
self.port.flush()?;
Ok(())
}
pub fn drain(&mut self) -> Result<()> {
self.port
.set_timeout(Duration::from_millis(200))
.ok();
let mut buf = [0u8; 1024];
loop {
match self.port.read(&mut buf) {
Ok(0) => break,
Ok(_) => continue,
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => break,
Err(e) => return Err(FaxError::Io(e)),
}
}
self.port
.set_timeout(Duration::from_secs(10))
.ok();
Ok(())
}
pub fn set_timeout(&mut self, timeout_ms: u64) -> Result<()> {
self.port
.set_timeout(Duration::from_millis(timeout_ms))
.map_err(Into::into)
}
pub fn device(&self) -> &str {
&self.device
}
pub fn baud_rate(&self) -> u32 {
self.baud_rate
}
}

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

@@ -0,0 +1,7 @@
pub mod driver;
pub mod at;
pub mod detect;
pub use driver::ModemDriver;
pub use at::AtChannel;
pub use detect::ModemInfo;

43
src/queue/job.rs Normal file
View File

@@ -0,0 +1,43 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub type JobId = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum JobStatus {
Queued,
Dialing,
Sending,
Completed,
Failed(String),
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaxJob {
pub id: JobId,
pub recipient: String,
pub document_path: String,
pub status: JobStatus,
pub pages: u32,
pub retries: u8,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl FaxJob {
pub fn new(recipient: String, document_path: String) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
recipient,
document_path,
status: JobStatus::Queued,
pages: 0,
retries: 0,
created_at: now,
updated_at: now,
}
}
}

5
src/queue/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod job;
pub mod store;
pub use job::{FaxJob, JobId, JobStatus};
pub use store::FaxQueue;

121
src/queue/store.rs Normal file
View File

@@ -0,0 +1,121 @@
use crate::error::Result;
use crate::queue::job::{FaxJob, JobId, JobStatus};
use std::collections::HashMap;
use std::path::Path;
#[cfg(feature = "server")]
use rusqlite::Connection;
pub enum FaxQueue {
InMemory(InMemoryStore),
#[cfg(feature = "server")]
Sqlite(SqliteStore),
}
pub struct InMemoryStore {
jobs: HashMap<JobId, FaxJob>,
}
#[cfg(feature = "server")]
pub struct SqliteStore {
conn: Connection,
}
impl FaxQueue {
pub fn new_in_memory() -> Self {
FaxQueue::InMemory(InMemoryStore {
jobs: HashMap::new(),
})
}
#[cfg(feature = "server")]
pub fn new_sqlite(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS fax_jobs (
id TEXT PRIMARY KEY,
recipient TEXT NOT NULL,
document_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Queued',
pages INTEGER DEFAULT 0,
retries INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
[],
)?;
Ok(FaxQueue::Sqlite(SqliteStore { conn }))
}
pub fn enqueue(&mut self, job: FaxJob) -> Result<()> {
match self {
FaxQueue::InMemory(s) => {
s.jobs.insert(job.id, job);
Ok(())
}
#[cfg(feature = "server")]
FaxQueue::Sqlite(s) => {
s.conn.execute(
"INSERT INTO fax_jobs (id, recipient, document_path, status, pages, retries, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
job.id.to_string(),
job.recipient,
job.document_path,
format!("{:?}", job.status),
job.pages,
job.retries,
job.created_at.to_rfc3339(),
job.updated_at.to_rfc3339(),
],
)?;
Ok(())
}
}
}
pub fn get(&self, id: &JobId) -> Option<FaxJob> {
match self {
FaxQueue::InMemory(s) => s.jobs.get(id).cloned(),
#[cfg(feature = "server")]
FaxQueue::Sqlite(_) => None, // TODO
}
}
pub fn update_status(&mut self, id: &JobId, status: JobStatus) -> Result<()> {
match self {
FaxQueue::InMemory(s) => {
if let Some(job) = s.jobs.get_mut(id) {
job.status = status;
job.updated_at = chrono::Utc::now();
}
Ok(())
}
#[cfg(feature = "server")]
FaxQueue::Sqlite(_s) => {
Ok(())
}
}
}
pub fn list(&self) -> Vec<FaxJob> {
match self {
FaxQueue::InMemory(s) => s.jobs.values().cloned().collect(),
#[cfg(feature = "server")]
FaxQueue::Sqlite(_) => Vec::new(),
}
}
pub fn pending_jobs(&self) -> Vec<FaxJob> {
match self {
FaxQueue::InMemory(s) => s
.jobs
.values()
.filter(|j| matches!(j.status, JobStatus::Queued))
.cloned()
.collect(),
#[cfg(feature = "server")]
FaxQueue::Sqlite(_) => Vec::new(),
}
}
}

62
tests/integration_test.rs Normal file
View File

@@ -0,0 +1,62 @@
use telfax::fax::hdlc::{build_hdlc_frame, parse_hdlc_frame, verify_fcs};
use telfax::fax::t4::{T4Codec, T4Encoding};
use telfax::modem::driver::ModemDriver;
#[test]
fn test_hdlc_frame_build_and_parse() {
let frame = build_hdlc_frame(0x28, &[0x00, 0x02, 0x05]);
assert!(frame.len() > 6);
assert_eq!(frame[0], 0x7E); // HDLC flag
let parsed = parse_hdlc_frame(&frame).unwrap();
assert_eq!(parsed.control, 0x28);
assert_eq!(parsed.information, vec![0x00, 0x02, 0x05]);
}
#[test]
fn test_hdlc_fcs_verification() {
let frame = build_hdlc_frame(0x42, &[]);
// FCS verification operates on the stuffed data minus the flags
let inner: Vec<u8> = frame[1..frame.len() - 1].to_vec();
assert!(verify_fcs(&inner));
}
#[test]
fn test_t4_group4_decode_simple() {
// Create a simple G4 encoded image (just a single white line)
// We'll test with G3 MH encoding since G4 is more complex
let _ = T4Codec::decode(
&[0x00],
1728,
1,
T4Encoding::Group4MMR,
);
// Just verify it doesn't panic
}
#[test]
fn test_document_from_image() {
use image::ImageBuffer;
// Create a 100x100 white grayscale image and encode as PNG
let img = image::DynamicImage::from(
ImageBuffer::from_fn(100, 100, |_x, _y| image::Luma([255u8]))
);
let mut bytes = std::io::Cursor::new(Vec::new());
img.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
let doc = telfax::document::convert::document_from_image(bytes.get_ref()).unwrap();
assert_eq!(doc.pages.len(), 1);
assert_eq!(doc.pages[0].width_pels, 100);
assert!(doc.pages[0].pixels.len() > 0);
}
#[test]
fn test_modem_driver_open() {
let device_paths = ["/dev/cu.usbmodem00000021", "/dev/cu.usbmodem*"];
if !device_paths.iter().any(|p| p.contains('*') || std::path::Path::new(p).exists()) {
return;
}
if std::path::Path::new("/dev/cu.usbmodem00000021").exists() {
let _ = ModemDriver::open("/dev/cu.usbmodem00000021", 115200);
}
}