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:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/target
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
data/
|
||||||
2642
Cargo.lock
generated
Normal file
2642
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
Cargo.toml
Normal file
63
Cargo.toml
Normal 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
3
src/api/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod routes;
|
||||||
|
|
||||||
|
pub use routes::start_server;
|
||||||
128
src/api/routes.rs
Normal file
128
src/api/routes.rs
Normal 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
97
src/config.rs
Normal 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
83
src/document/convert.rs
Normal 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
5
src/document/mod.rs
Normal 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
35
src/document/tiff.rs
Normal 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
74
src/error.rs
Normal 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
27
src/fax/class1/mod.rs
Normal 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
35
src/fax/class1/recv.rs
Normal 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
219
src/fax/class1/send.rs
Normal 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
103
src/fax/class1/session.rs
Normal 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
150
src/fax/hdlc.rs
Normal 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
7
src/fax/mod.rs
Normal 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
91
src/fax/negotiate.rs
Normal 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
106
src/fax/t4.rs
Normal 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
31
src/lib.rs
Normal 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
161
src/main.rs
Normal 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
62
src/modem/at.rs
Normal 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
110
src/modem/detect.rs
Normal 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
137
src/modem/driver.rs
Normal 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
7
src/modem/mod.rs
Normal 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
43
src/queue/job.rs
Normal 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
5
src/queue/mod.rs
Normal 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
121
src/queue/store.rs
Normal 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
62
tests/integration_test.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user