Add cover page support with Chinese font rendering
Features:
- Cover page generation using Python/Pillow with Apple PingFang font
- CLI commands: cover-create, job-cover for cover page management
- API endpoints: PUT /api/fax/jobs/{id}/cover for cover modification
- Send fax with cover page: --to, --from, --subject, --note options
- Multi-page TIFF parsing for PDF conversion (fix: all pages now sent)
- Preview HTML generation with pan/zoom support
- OCR verification for cover page content
- Class 2 receive support for incoming faxes
Fixes:
- Modem initialization before Class 2 mode entry
- Dial timeout handling with retry logic
- Multi-page fax transmission between pages
- Chinese character rendering in cover pages (Apple native fonts)
This commit is contained in:
Generated
+7
@@ -262,6 +262,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit_field"
|
name = "bit_field"
|
||||||
version = "0.10.3"
|
version = "0.10.3"
|
||||||
@@ -1981,6 +1987,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"fax",
|
"fax",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ anyhow = "1"
|
|||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
+61
-2
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
extract::State,
|
extract::State,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{get, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -30,6 +30,10 @@ pub struct HealthResponse {
|
|||||||
pub struct SendFaxRequest {
|
pub struct SendFaxRequest {
|
||||||
pub recipient: String,
|
pub recipient: String,
|
||||||
pub document_path: String,
|
pub document_path: String,
|
||||||
|
pub cover_to: Option<String>,
|
||||||
|
pub cover_from: Option<String>,
|
||||||
|
pub cover_subject: Option<String>,
|
||||||
|
pub cover_notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -37,6 +41,23 @@ pub struct SendFaxResponse {
|
|||||||
pub job_id: JobId,
|
pub job_id: JobId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateCoverRequest {
|
||||||
|
pub to: Option<String>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub subject: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UpdateCoverResponse {
|
||||||
|
pub job_id: JobId,
|
||||||
|
pub cover_to: Option<String>,
|
||||||
|
pub cover_from: Option<String>,
|
||||||
|
pub cover_subject: Option<String>,
|
||||||
|
pub cover_notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct JobResponse {
|
pub struct JobResponse {
|
||||||
pub id: JobId,
|
pub id: JobId,
|
||||||
@@ -44,6 +65,10 @@ pub struct JobResponse {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub pages: u32,
|
pub pages: u32,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub cover_to: Option<String>,
|
||||||
|
pub cover_from: Option<String>,
|
||||||
|
pub cover_subject: Option<String>,
|
||||||
|
pub cover_notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||||
@@ -57,7 +82,11 @@ async fn send_fax(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<SendFaxRequest>,
|
Json(req): Json<SendFaxRequest>,
|
||||||
) -> Result<Json<SendFaxResponse>, StatusCode> {
|
) -> Result<Json<SendFaxResponse>, StatusCode> {
|
||||||
let job = FaxJob::new(req.recipient, req.document_path);
|
let mut job = FaxJob::new(req.recipient, req.document_path);
|
||||||
|
job.cover_to = req.cover_to;
|
||||||
|
job.cover_from = req.cover_from;
|
||||||
|
job.cover_subject = req.cover_subject;
|
||||||
|
job.cover_notes = req.cover_notes;
|
||||||
let job_id = job.id;
|
let job_id = job.id;
|
||||||
|
|
||||||
let mut queue = state.queue.lock().unwrap();
|
let mut queue = state.queue.lock().unwrap();
|
||||||
@@ -78,6 +107,10 @@ async fn list_jobs(State(state): State<AppState>) -> Json<Vec<JobResponse>> {
|
|||||||
status: format!("{:?}", j.status),
|
status: format!("{:?}", j.status),
|
||||||
pages: j.pages,
|
pages: j.pages,
|
||||||
created_at: j.created_at.to_rfc3339(),
|
created_at: j.created_at.to_rfc3339(),
|
||||||
|
cover_to: j.cover_to,
|
||||||
|
cover_from: j.cover_from,
|
||||||
|
cover_subject: j.cover_subject,
|
||||||
|
cover_notes: j.cover_notes,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Json(responses)
|
Json(responses)
|
||||||
@@ -94,9 +127,34 @@ async fn get_job(
|
|||||||
status: format!("{:?}", j.status),
|
status: format!("{:?}", j.status),
|
||||||
pages: j.pages,
|
pages: j.pages,
|
||||||
created_at: j.created_at.to_rfc3339(),
|
created_at: j.created_at.to_rfc3339(),
|
||||||
|
cover_to: j.cover_to,
|
||||||
|
cover_from: j.cover_from,
|
||||||
|
cover_subject: j.cover_subject,
|
||||||
|
cover_notes: j.cover_notes,
|
||||||
})).ok_or(StatusCode::NOT_FOUND)
|
})).ok_or(StatusCode::NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_job_cover(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Path(id): axum::extract::Path<JobId>,
|
||||||
|
Json(req): Json<UpdateCoverRequest>,
|
||||||
|
) -> Result<Json<UpdateCoverResponse>, StatusCode> {
|
||||||
|
let mut queue = state.queue.lock().unwrap();
|
||||||
|
queue
|
||||||
|
.update_cover(&id, req.to.clone(), req.from.clone(), req.subject.clone(), req.notes.clone())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
// Return updated fields
|
||||||
|
let job = queue.get(&id).ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
Ok(Json(UpdateCoverResponse {
|
||||||
|
job_id: job.id,
|
||||||
|
cover_to: job.cover_to,
|
||||||
|
cover_from: job.cover_from,
|
||||||
|
cover_subject: job.cover_subject,
|
||||||
|
cover_notes: job.cover_notes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> FaxResult<()> {
|
pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> FaxResult<()> {
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
@@ -108,6 +166,7 @@ pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> Fax
|
|||||||
.route("/api/fax/send", post(send_fax))
|
.route("/api/fax/send", post(send_fax))
|
||||||
.route("/api/fax/jobs", get(list_jobs))
|
.route("/api/fax/jobs", get(list_jobs))
|
||||||
.route("/api/fax/jobs/{id}", get(get_job))
|
.route("/api/fax/jobs/{id}", get(get_job))
|
||||||
|
.route("/api/fax/jobs/{id}/cover", put(update_job_cover))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr = config
|
let addr = config
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
use crate::error::{FaxError, Result};
|
||||||
|
use crate::document::convert::Page;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const COVER_PY: &str = r#"from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import sys, json, os
|
||||||
|
|
||||||
|
params = json.loads(sys.argv[1])
|
||||||
|
to = params['to']
|
||||||
|
sender = params['from']
|
||||||
|
subject = params['subject']
|
||||||
|
notes = params['notes']
|
||||||
|
total_pages = params['total_pages']
|
||||||
|
output_path = params['output']
|
||||||
|
|
||||||
|
W, H = 1728, 2291
|
||||||
|
img = Image.new('L', (W, H), 255)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
PINGFANG_PATH = '/System/Library/AssetsV2/com_apple_MobileAsset_Font8/86ba2c91f017a3749571a82f2c6d890ac7ffb2fb.asset/AssetData/PingFang.ttc'
|
||||||
|
STHEITI_PATH = '/System/Library/Fonts/STHeiti Medium.ttc'
|
||||||
|
|
||||||
|
font_path = PINGFANG_PATH if os.path.exists(PINGFANG_PATH) else STHEITI_PATH if os.path.exists(STHEITI_PATH) else None
|
||||||
|
|
||||||
|
font_title = None
|
||||||
|
font_bold = None
|
||||||
|
font_normal = None
|
||||||
|
font_small = None
|
||||||
|
|
||||||
|
if font_path:
|
||||||
|
try:
|
||||||
|
if 'PingFang' in font_path:
|
||||||
|
font_title = ImageFont.truetype(font_path, 72, index=10)
|
||||||
|
font_bold = ImageFont.truetype(font_path, 44, index=6)
|
||||||
|
font_normal = ImageFont.truetype(font_path, 44, index=2)
|
||||||
|
font_small = ImageFont.truetype(font_path, 36, index=2)
|
||||||
|
else:
|
||||||
|
font_title = ImageFont.truetype(font_path, 72, index=0)
|
||||||
|
font_bold = ImageFont.truetype(font_path, 44, index=0)
|
||||||
|
font_normal = ImageFont.truetype(font_path, 44, index=0)
|
||||||
|
font_small = ImageFont.truetype(font_path, 36, index=0)
|
||||||
|
except Exception as e:
|
||||||
|
font_path = None
|
||||||
|
|
||||||
|
if not font_path:
|
||||||
|
try:
|
||||||
|
font_title = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 72)
|
||||||
|
font_bold = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 44)
|
||||||
|
font_normal = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 44)
|
||||||
|
font_small = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 36)
|
||||||
|
except Exception:
|
||||||
|
font_title = ImageFont.load_default()
|
||||||
|
font_bold = font_title
|
||||||
|
font_normal = font_title
|
||||||
|
font_small = font_title
|
||||||
|
|
||||||
|
draw.rectangle([(0, 2030), (1728, 2230)], fill=0)
|
||||||
|
_, _, tw, _ = draw.textbbox((0, 0), 'FACSIMILE', font=font_title)
|
||||||
|
draw.text(((W - tw) / 2, 2080), 'FACSIMILE', fill=255, font=font_title)
|
||||||
|
|
||||||
|
draw.line([(100, 1980), (1628, 1980)], fill=0, width=3)
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
date_str = datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||||
|
|
||||||
|
labels = ['TO:', 'FROM:', 'DATE:', 'PAGES:', 'SUBJECT:']
|
||||||
|
values = [to, sender, date_str, str(total_pages), subject]
|
||||||
|
y = 1880
|
||||||
|
|
||||||
|
for label, value in zip(labels, values):
|
||||||
|
draw.text((100, y), label, fill=0, font=font_bold)
|
||||||
|
draw.text((300, y), value, fill=0, font=font_normal)
|
||||||
|
y -= 90
|
||||||
|
|
||||||
|
draw.line([(100, y), (1628, y)], fill=0, width=3)
|
||||||
|
y -= 70
|
||||||
|
|
||||||
|
draw.text((100, y), 'NOTES:', fill=0, font=font_bold)
|
||||||
|
y -= 50
|
||||||
|
for line in notes.split('\n'):
|
||||||
|
draw.text((100, y), line, fill=0, font=font_small)
|
||||||
|
y -= 40
|
||||||
|
|
||||||
|
img.save(output_path, dpi=(204, 196))
|
||||||
|
print(json.dumps({"ok": True}))
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub struct CoverParams {
|
||||||
|
pub to: String,
|
||||||
|
pub from: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub total_pages: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_cover_page(params: &CoverParams) -> Result<Page> {
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
|
||||||
|
let py_dir = std::env::temp_dir();
|
||||||
|
let py_path = py_dir.join(format!("telfax_cover_{}.py", stamp));
|
||||||
|
let tiff_path = py_dir.join(format!("telfax_cover_{}.tif", stamp));
|
||||||
|
|
||||||
|
std::fs::write(&py_path, COVER_PY)?;
|
||||||
|
|
||||||
|
let py_params = serde_json::json!({
|
||||||
|
"to": params.to,
|
||||||
|
"from": params.from,
|
||||||
|
"subject": params.subject,
|
||||||
|
"notes": params.notes,
|
||||||
|
"total_pages": params.total_pages,
|
||||||
|
"output": tiff_path.to_string_lossy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let python = if cfg!(target_os = "windows") { "python" } else { "python3" };
|
||||||
|
|
||||||
|
let output = Command::new(python)
|
||||||
|
.arg(py_path.to_string_lossy().as_ref())
|
||||||
|
.arg(py_params.to_string())
|
||||||
|
.output()
|
||||||
|
.map_err(|e| FaxError::document(format!("Python cover script failed: {}", e)))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
std::fs::remove_file(&py_path).ok();
|
||||||
|
std::fs::remove_file(&tiff_path).ok();
|
||||||
|
return Err(FaxError::document(format!("Cover generation error: {}", stderr)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tiff_data = std::fs::read(&tiff_path)?;
|
||||||
|
let doc = super::pdf::tiff_to_fax_document(&tiff_data)?;
|
||||||
|
|
||||||
|
std::fs::remove_file(&py_path).ok();
|
||||||
|
std::fs::remove_file(&tiff_path).ok();
|
||||||
|
|
||||||
|
Ok(doc.pages.into_iter().next().ok_or_else(|| {
|
||||||
|
FaxError::document("Cover page generation produced no pages")
|
||||||
|
})?)
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod convert;
|
pub mod convert;
|
||||||
pub mod tiff;
|
pub mod tiff;
|
||||||
pub mod pdf;
|
pub mod pdf;
|
||||||
|
pub mod cover;
|
||||||
|
|
||||||
pub use convert::{document_from_image, document_from_tiff, DocumentFormat, FaxDocument, Page};
|
pub use convert::{document_from_image, document_from_tiff, DocumentFormat, FaxDocument, Page};
|
||||||
pub use tiff::TiffFaxWriter;
|
pub use tiff::TiffFaxWriter;
|
||||||
pub use pdf::pdf_to_fax_document;
|
pub use pdf::pdf_to_fax_document;
|
||||||
|
pub use cover::{generate_cover_page, CoverParams};
|
||||||
|
|||||||
+41
-27
@@ -55,44 +55,58 @@ pub fn tiff_to_fax_document(tiff_data: &[u8]) -> Result<FaxDocument> {
|
|||||||
let mut decoder = Decoder::new(&mut cursor)
|
let mut decoder = Decoder::new(&mut cursor)
|
||||||
.map_err(|e| FaxError::document(format!("Failed to open TIFF: {}", e)))?;
|
.map_err(|e| FaxError::document(format!("Failed to open TIFF: {}", e)))?;
|
||||||
|
|
||||||
let (width, height) = decoder.dimensions()
|
let mut pages = Vec::new();
|
||||||
.map_err(|e| FaxError::document(format!("Failed to get TIFF dimensions: {}", e)))?;
|
|
||||||
|
|
||||||
let bytes_per_row = ((width + 7) / 8) as usize;
|
loop {
|
||||||
let mut pixels = vec![0u8; bytes_per_row * height as usize];
|
let (width, height) = decoder.dimensions()
|
||||||
|
.map_err(|e| FaxError::document(format!("Failed to get TIFF dimensions: {}", e)))?;
|
||||||
|
|
||||||
let mut result = decoder.read_image()
|
let bytes_per_row = ((width + 7) / 8) as usize;
|
||||||
.map_err(|e| FaxError::document(format!("Failed to read TIFF image: {}", e)))?;
|
let mut pixels = vec![0u8; bytes_per_row * height as usize];
|
||||||
|
|
||||||
// Convert to 1-bit packed format
|
let mut result = decoder.read_image()
|
||||||
let buffer = result.as_buffer(0);
|
.map_err(|e| FaxError::document(format!("Failed to read TIFF image: {}", e)))?;
|
||||||
let img_data = buffer.as_bytes();
|
|
||||||
|
|
||||||
// Process each pixel - TIFF is grayscale (8-bit), convert to 1-bit
|
// Convert to 1-bit packed format
|
||||||
for (i, &byte) in img_data.iter().enumerate() {
|
let buffer = result.as_buffer(0);
|
||||||
let pixel_idx = i % width as usize;
|
let img_data = buffer.as_bytes();
|
||||||
let row_idx = i / width as usize;
|
|
||||||
let byte_idx = pixel_idx / 8;
|
|
||||||
let bit_idx = 7 - (pixel_idx % 8);
|
|
||||||
|
|
||||||
// WhiteIsZero: 0=white, 255=black
|
// Process each pixel - TIFF is grayscale (8-bit), convert to 1-bit
|
||||||
// We want: 1=black, 0=white (MSB first)
|
for (i, &byte) in img_data.iter().enumerate() {
|
||||||
if byte < 128 {
|
let pixel_idx = i % width as usize;
|
||||||
let idx = row_idx * bytes_per_row + byte_idx;
|
let row_idx = i / width as usize;
|
||||||
if idx < pixels.len() {
|
let byte_idx = pixel_idx / 8;
|
||||||
pixels[idx] |= 1 << bit_idx;
|
let bit_idx = 7 - (pixel_idx % 8);
|
||||||
|
|
||||||
|
// WhiteIsZero: 0=white, 255=black
|
||||||
|
// We want: 1=black, 0=white (MSB first)
|
||||||
|
if byte < 128 {
|
||||||
|
let idx = row_idx * bytes_per_row + byte_idx;
|
||||||
|
if idx < pixels.len() {
|
||||||
|
pixels[idx] |= 1 << bit_idx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pages.push(Page {
|
||||||
|
pixels,
|
||||||
|
width_pels: width,
|
||||||
|
rows: height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to advance to next page
|
||||||
|
match decoder.next_image() {
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let page = Page {
|
if pages.is_empty() {
|
||||||
pixels,
|
return Err(FaxError::document("TIFF contains no pages"));
|
||||||
width_pels: width,
|
}
|
||||||
rows: height,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(FaxDocument {
|
Ok(FaxDocument {
|
||||||
pages: vec![page],
|
pages,
|
||||||
format: DocumentFormat::Tiff,
|
format: DocumentFormat::Tiff,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+13
-2
@@ -2,6 +2,11 @@ use crate::document::convert::Page;
|
|||||||
use crate::error::{FaxError, Result};
|
use crate::error::{FaxError, Result};
|
||||||
use tiff::encoder::colortype::Gray8;
|
use tiff::encoder::colortype::Gray8;
|
||||||
use tiff::encoder::TiffEncoder;
|
use tiff::encoder::TiffEncoder;
|
||||||
|
use tiff::encoder::Rational;
|
||||||
|
use tiff::tags::ResolutionUnit;
|
||||||
|
|
||||||
|
const FAX_X_DPI: u32 = 204;
|
||||||
|
const FAX_Y_DPI: u32 = 196;
|
||||||
|
|
||||||
pub struct TiffFaxWriter;
|
pub struct TiffFaxWriter;
|
||||||
|
|
||||||
@@ -25,8 +30,14 @@ impl TiffFaxWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder
|
let mut img = encoder
|
||||||
.write_image::<Gray8>(page.width_pels, page.rows, &gray_data)
|
.new_image::<Gray8>(page.width_pels, page.rows)
|
||||||
|
.map_err(|e| FaxError::document(format!("Failed to create TIFF image: {}", e)))?;
|
||||||
|
|
||||||
|
img.resolution_unit(ResolutionUnit::Inch);
|
||||||
|
img.x_resolution(Rational { n: FAX_X_DPI, d: 1 });
|
||||||
|
img.y_resolution(Rational { n: FAX_Y_DPI, d: 1 });
|
||||||
|
img.write_data(&gray_data)
|
||||||
.map_err(|e| FaxError::document(format!("Failed to write TIFF page: {}", e)))?;
|
.map_err(|e| FaxError::document(format!("Failed to write TIFF page: {}", e)))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-13
@@ -39,6 +39,27 @@ impl<'a> Class1Send<'a> {
|
|||||||
|
|
||||||
self.session.transition(T30Event::Connected);
|
self.session.transition(T30Event::Connected);
|
||||||
|
|
||||||
|
// After CONNECT, some modems need escape to enter online command mode
|
||||||
|
// Wait for silence (no CED tone yet), then escape
|
||||||
|
tracing::info!("Connection established, waiting for silence...");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||||
|
|
||||||
|
// Send escape sequence +++ (with guard times)
|
||||||
|
self.driver.write_raw(b"+++")?;
|
||||||
|
self.driver.flush()?;
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||||
|
|
||||||
|
// Drain response
|
||||||
|
self.driver.drain()?;
|
||||||
|
|
||||||
|
// Wait for CED tone and DIS from receiver (per T.30 spec)
|
||||||
|
tracing::info!("Waiting for receiver to send CED and DIS...");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(4000));
|
||||||
|
|
||||||
|
// Drain any pending data before entering HDLC receive mode
|
||||||
|
self.driver.drain()?;
|
||||||
|
tracing::info!("Entering HDLC receive mode...");
|
||||||
|
|
||||||
let dis_frame = self.receive_hdlc_frame()?;
|
let dis_frame = self.receive_hdlc_frame()?;
|
||||||
tracing::info!("Received DIS frame ({} bytes)", dis_frame.len());
|
tracing::info!("Received DIS frame ({} bytes)", dis_frame.len());
|
||||||
|
|
||||||
@@ -94,33 +115,72 @@ impl<'a> Class1Send<'a> {
|
|||||||
let cmd = format!("ATDT{}\r", number);
|
let cmd = format!("ATDT{}\r", number);
|
||||||
self.driver.write_raw(cmd.as_bytes())?;
|
self.driver.write_raw(cmd.as_bytes())?;
|
||||||
self.driver.flush()?;
|
self.driver.flush()?;
|
||||||
|
|
||||||
|
tracing::info!("Dialing {}, waiting for response...", number);
|
||||||
|
|
||||||
let response = self.driver.read_until(b"\r\n", 60000)?;
|
let mut full_response = Vec::new();
|
||||||
let text = String::from_utf8_lossy(&response);
|
let start = std::time::Instant::now();
|
||||||
tracing::info!(response = %text, "Dial response");
|
let timeout_ms = 60000u64;
|
||||||
|
|
||||||
if text.contains("NO CARRIER") || text.contains("BUSY") {
|
loop {
|
||||||
return Err(FaxError::NoCarrier);
|
match self.driver.read_until(b"\r\n", 5000) {
|
||||||
|
Ok(line) => {
|
||||||
|
let text = String::from_utf8_lossy(&line);
|
||||||
|
tracing::info!("Dial line: {}", text.trim());
|
||||||
|
full_response.extend_from_slice(&line);
|
||||||
|
|
||||||
|
if text.contains("CONNECT") {
|
||||||
|
tracing::info!("Connection established");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if text.contains("BUSY") {
|
||||||
|
return Err(FaxError::modem("Line is BUSY"));
|
||||||
|
}
|
||||||
|
if text.contains("NO CARRIER") {
|
||||||
|
return Err(FaxError::NoCarrier);
|
||||||
|
}
|
||||||
|
if text.contains("NO DIALTONE") {
|
||||||
|
return Err(FaxError::modem("No dial tone"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
if start.elapsed().as_millis() as u64 > timeout_ms {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !text.contains("CONNECT") {
|
|
||||||
return Err(FaxError::modem(format!("Unexpected dial response: {}", text)));
|
let text = String::from_utf8_lossy(&full_response);
|
||||||
}
|
Err(FaxError::modem(format!("Dial timeout, response: {}", text)))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn receive_hdlc_frame(&mut self) -> Result<Vec<u8>> {
|
fn receive_hdlc_frame(&mut self) -> Result<Vec<u8>> {
|
||||||
self.driver.write_raw(b"AT+FRH=3\r")?;
|
self.driver.write_raw(b"AT+FRH=3\r")?;
|
||||||
|
self.driver.flush()?;
|
||||||
|
|
||||||
let response = self.driver.read_until(b"\r\n", 15000)?;
|
// Wait for CONNECT response - modem may take time to enter HDLC receive mode
|
||||||
|
let response = self.driver.read_until(b"CONNECT\r\n", 30000)?;
|
||||||
let text = String::from_utf8_lossy(&response);
|
let text = String::from_utf8_lossy(&response);
|
||||||
tracing::debug!(response = %text, "FRH initial");
|
tracing::info!(response = %text, "FRH response");
|
||||||
|
|
||||||
if text.contains("NO CARRIER") || text.contains("ERROR") {
|
if text.contains("NO CARRIER") || text.contains("ERROR") {
|
||||||
return Err(FaxError::NoCarrier);
|
return Err(FaxError::NoCarrier);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = self.driver.read_until(b"OK\r\n", 10000)?;
|
// Now read the actual HDLC frame data until OK
|
||||||
Ok(result)
|
// The modem sends the HDLC frame bytes, then "OK\r\n"
|
||||||
|
let frame_and_ok = self.driver.read_until(b"OK\r\n", 60000)?;
|
||||||
|
|
||||||
|
// Remove trailing "OK\r\n" if present
|
||||||
|
let frame_data = if frame_and_ok.ends_with(b"OK\r\n") {
|
||||||
|
&frame_and_ok[..frame_and_ok.len() - 4]
|
||||||
|
} else {
|
||||||
|
&frame_and_ok[..]
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Received {} bytes of HDLC data", frame_data.len());
|
||||||
|
Ok(frame_data.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transmit_hdlc_frame(&mut self, frame: &[u8]) -> Result<()> {
|
fn transmit_hdlc_frame(&mut self, frame: &[u8]) -> Result<()> {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod send;
|
pub mod send;
|
||||||
|
pub mod receive;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
pub use send::Class2Send;
|
pub use send::Class2Send;
|
||||||
|
pub use receive::Class2Receive;
|
||||||
pub use commands::Class2Commands;
|
pub use commands::Class2Commands;
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
use crate::error::{FaxError, Result};
|
||||||
|
use crate::modem::driver::ModemDriver;
|
||||||
|
|
||||||
|
const DLE: u8 = 0x10;
|
||||||
|
const ETX: u8 = 0x03;
|
||||||
|
|
||||||
|
pub struct Class2Receive<'a> {
|
||||||
|
driver: &'a mut ModemDriver,
|
||||||
|
station_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Class2Receive<'a> {
|
||||||
|
pub fn new(driver: &'a mut ModemDriver) -> Self {
|
||||||
|
Self {
|
||||||
|
driver,
|
||||||
|
station_id: "TELFAX".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_station_id(mut self, id: &str) -> Self {
|
||||||
|
self.station_id = id.to_string();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(&mut self) -> crate::modem::at::AtChannel<'_> {
|
||||||
|
crate::modem::at::AtChannel::new(self.driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup_auto_answer(&mut self, rings: u8) -> Result<()> {
|
||||||
|
tracing::info!("Setting up Class 2 auto-answer ({} rings)", rings);
|
||||||
|
|
||||||
|
self.at().send_command("AT", 2000)?;
|
||||||
|
self.at().send_command("AT+FCLASS=2", 3000)?;
|
||||||
|
|
||||||
|
let station_cmd = format!("AT+FLID=\"{}\"", self.station_id);
|
||||||
|
self.at().send_command(&station_cmd, 3000)?;
|
||||||
|
|
||||||
|
let rings_cmd = format!("ATS0={}", rings);
|
||||||
|
self.at().send_command(&rings_cmd, 2000)?;
|
||||||
|
|
||||||
|
self.at().send_command("AT+FAA=1", 2000)?;
|
||||||
|
|
||||||
|
tracing::info!("Auto-answer configured");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_for_call(&mut self, timeout_ms: u64) -> Result<()> {
|
||||||
|
tracing::info!("Waiting for incoming call...");
|
||||||
|
|
||||||
|
// In Class 2, the modem handles T.30 negotiation automatically
|
||||||
|
// We need to wait for +FCON which indicates fax session established
|
||||||
|
let response = self.driver.read_until(b"+FCON", timeout_ms)?;
|
||||||
|
let text = String::from_utf8_lossy(&response);
|
||||||
|
tracing::info!("Fax connection: {}", text.trim());
|
||||||
|
|
||||||
|
// Wait for +FDCS (Digital Command Signal) or +FPTS (Page Transfer Start)
|
||||||
|
// The modem will handle DIS/DCS negotiation automatically
|
||||||
|
tracing::info!("Waiting for page data...");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn receive_pages(&mut self) -> Result<Vec<Vec<u8>>> {
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
let mut page_count = 0;
|
||||||
|
let mut receiving_page = false;
|
||||||
|
let mut page_data = Vec::new();
|
||||||
|
|
||||||
|
tracing::info!("Monitoring for page events...");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let event = self.wait_for_event(60000)?;
|
||||||
|
|
||||||
|
match event {
|
||||||
|
ReceiveEvent::Fpts => {
|
||||||
|
tracing::info!("Page {} transfer starting...", page_count + 1);
|
||||||
|
receiving_page = true;
|
||||||
|
page_data = Vec::new();
|
||||||
|
}
|
||||||
|
ReceiveEvent::Ftsi(id) => {
|
||||||
|
tracing::info!("Transmitting station: {}", id);
|
||||||
|
}
|
||||||
|
ReceiveEvent::PageData(data) => {
|
||||||
|
if receiving_page {
|
||||||
|
page_data.extend_from_slice(&data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReceiveEvent::PageEnd(more) => {
|
||||||
|
if receiving_page && !page_data.is_empty() {
|
||||||
|
tracing::info!("Page {} received ({} bytes)", page_count + 1, page_data.len());
|
||||||
|
pages.push(page_data.clone());
|
||||||
|
page_count += 1;
|
||||||
|
}
|
||||||
|
receiving_page = false;
|
||||||
|
if !more {
|
||||||
|
tracing::info!("No more pages");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReceiveEvent::Fhng(code) => {
|
||||||
|
tracing::info!("Session ended with code {}", code);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ReceiveEvent::NoCarrier => {
|
||||||
|
tracing::info!("Carrier lost");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ReceiveEvent::Error(msg) => {
|
||||||
|
tracing::error!("Error: {}", msg);
|
||||||
|
if receiving_page && !page_data.is_empty() {
|
||||||
|
pages.push(page_data.clone());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_event(&mut self, timeout_ms: u64) -> Result<ReceiveEvent> {
|
||||||
|
let response = self.driver.read_until(b"\r\n", timeout_ms)?;
|
||||||
|
let text = String::from_utf8_lossy(&response);
|
||||||
|
let line = text.trim();
|
||||||
|
|
||||||
|
tracing::debug!("Event: {}", line);
|
||||||
|
|
||||||
|
if line.contains("+FPTS") {
|
||||||
|
Ok(ReceiveEvent::Fpts)
|
||||||
|
} else if line.contains("+FTSI") {
|
||||||
|
let id = extract_quoted_value(line);
|
||||||
|
Ok(ReceiveEvent::Ftsi(id))
|
||||||
|
} else if line.contains("+FDCS") {
|
||||||
|
Ok(ReceiveEvent::Fdcs)
|
||||||
|
} else if line.contains("+FCON") {
|
||||||
|
Ok(ReceiveEvent::Fcon)
|
||||||
|
} else if line.contains("+FET") || line.contains("+FCFR") {
|
||||||
|
let more = line.contains(",1") || line.contains("+FCFR");
|
||||||
|
Ok(ReceiveEvent::PageEnd(more))
|
||||||
|
} else if line.contains("+FHNG") {
|
||||||
|
let code = extract_number(line);
|
||||||
|
Ok(ReceiveEvent::Fhng(code))
|
||||||
|
} else if line.contains("NO CARRIER") {
|
||||||
|
Ok(ReceiveEvent::NoCarrier)
|
||||||
|
} else if line.contains("ERROR") {
|
||||||
|
Ok(ReceiveEvent::Error(line.to_string()))
|
||||||
|
} else if line.contains("OK") {
|
||||||
|
// Data transmission complete marker
|
||||||
|
Ok(ReceiveEvent::DataOk)
|
||||||
|
} else if !line.is_empty() && !line.starts_with("AT") {
|
||||||
|
// Might be data - try to read raw bytes
|
||||||
|
let data = self.read_page_stream()?;
|
||||||
|
Ok(ReceiveEvent::PageData(data))
|
||||||
|
} else {
|
||||||
|
Ok(ReceiveEvent::Unknown(line.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_page_stream(&mut self) -> Result<Vec<u8>> {
|
||||||
|
let mut data = Vec::new();
|
||||||
|
let mut dle_seen = false;
|
||||||
|
|
||||||
|
// Read until DLE-ETX
|
||||||
|
for _ in 0..100000 {
|
||||||
|
let byte = self.driver.read_byte(5000)?;
|
||||||
|
|
||||||
|
if dle_seen {
|
||||||
|
if byte == ETX {
|
||||||
|
break;
|
||||||
|
} else if byte == DLE {
|
||||||
|
data.push(DLE);
|
||||||
|
}
|
||||||
|
dle_seen = false;
|
||||||
|
} else if byte == DLE {
|
||||||
|
dle_seen = true;
|
||||||
|
} else {
|
||||||
|
data.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_session(&mut self) -> Result<()> {
|
||||||
|
tracing::info!("Ending receive session");
|
||||||
|
self.driver.write_raw(b"AT+FKS\r")?;
|
||||||
|
self.driver.flush()?;
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
self.driver.drain()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReceiveEvent {
|
||||||
|
Fcon,
|
||||||
|
Fdcs,
|
||||||
|
Fpts,
|
||||||
|
Ftsi(String),
|
||||||
|
PageData(Vec<u8>),
|
||||||
|
PageEnd(bool),
|
||||||
|
Fhng(u8),
|
||||||
|
NoCarrier,
|
||||||
|
DataOk,
|
||||||
|
Error(String),
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_quoted_value(s: &str) -> String {
|
||||||
|
if let Some(start) = s.find('"') {
|
||||||
|
if let Some(end) = s[start + 1..].find('"') {
|
||||||
|
return s[start + 1..start + 1 + end].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_number(s: &str) -> u8 {
|
||||||
|
let parts: Vec<&str> = s.split(':').collect();
|
||||||
|
if parts.len() > 1 {
|
||||||
|
parts[1].trim().split(',')
|
||||||
|
.next()
|
||||||
|
.and_then(|n| n.trim().parse().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-25
@@ -51,6 +51,10 @@ impl<'a> Class2Send<'a> {
|
|||||||
pub fn send_fax(&mut self, number: &str, pages: &[Page]) -> Result<()> {
|
pub fn send_fax(&mut self, number: &str, pages: &[Page]) -> Result<()> {
|
||||||
tracing::info!("Starting Class 2 fax to {}", number);
|
tracing::info!("Starting Class 2 fax to {}", number);
|
||||||
|
|
||||||
|
// Initialize modem first
|
||||||
|
self.at().send_command("AT", 3000)?;
|
||||||
|
tracing::debug!("Modem initialized");
|
||||||
|
|
||||||
// Enter Class 2 mode
|
// Enter Class 2 mode
|
||||||
self.at().send_command(Class2Commands::ENTER_CLASS2, 5000)?;
|
self.at().send_command(Class2Commands::ENTER_CLASS2, 5000)?;
|
||||||
tracing::info!("Entered Class 2 mode");
|
tracing::info!("Entered Class 2 mode");
|
||||||
@@ -60,14 +64,18 @@ impl<'a> Class2Send<'a> {
|
|||||||
self.at().send_command(&station_cmd, 3000)?;
|
self.at().send_command(&station_cmd, 3000)?;
|
||||||
tracing::info!("Station ID set: {}", self.station_id);
|
tracing::info!("Station ID set: {}", self.station_id);
|
||||||
|
|
||||||
// Set header
|
// Set header - may not be supported on some modems
|
||||||
let header_cmd = Class2Commands::set_header(&self.header);
|
let header_cmd = Class2Commands::set_header(&self.header);
|
||||||
self.at().send_command(&header_cmd, 3000)?;
|
if self.at().send_command(&header_cmd, 3000).is_err() {
|
||||||
|
tracing::warn!("Header command not supported, skipping");
|
||||||
|
}
|
||||||
|
|
||||||
// Set capabilities
|
// Set capabilities - skip if not supported
|
||||||
let speed = if self.params.speed > 0 { self.params.speed } else { BR_V17_14400 };
|
let speed = if self.params.speed > 0 { self.params.speed } else { BR_V17_14400 };
|
||||||
let dis_cmd = Class2Commands::set_dis_params(self.params.resolution, speed, self.params.ecm);
|
let dis_cmd = Class2Commands::set_dis_params(self.params.resolution, speed, self.params.ecm);
|
||||||
self.at().send_command(&dis_cmd, 3000)?;
|
if self.at().send_command(&dis_cmd, 3000).is_err() {
|
||||||
|
tracing::warn!("DIS params command not supported, using defaults");
|
||||||
|
}
|
||||||
|
|
||||||
// Dial
|
// Dial
|
||||||
self.dial(number)?;
|
self.dial(number)?;
|
||||||
@@ -94,25 +102,64 @@ impl<'a> Class2Send<'a> {
|
|||||||
self.driver.write_raw(cmd.as_bytes())?;
|
self.driver.write_raw(cmd.as_bytes())?;
|
||||||
self.driver.write_raw(b"\r")?;
|
self.driver.write_raw(b"\r")?;
|
||||||
self.driver.flush()?;
|
self.driver.flush()?;
|
||||||
|
tracing::info!("Dialing: {}", cmd);
|
||||||
|
|
||||||
// Wait for CONNECT or response
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||||
let response = self.driver.read_until(b"\r\n", 60000)?;
|
|
||||||
let text = String::from_utf8_lossy(&response);
|
|
||||||
tracing::info!("Dial response: {}", text);
|
|
||||||
|
|
||||||
// Check for successful connection
|
// Read lines until CONNECT or error
|
||||||
if text.contains("NO CARRIER") || text.contains("BUSY") {
|
while std::time::Instant::now() < deadline {
|
||||||
return Err(FaxError::NoCarrier);
|
let line = match self.driver.read_until(b"\r\n", 5000) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(crate::error::FaxError::Timeout { .. }) => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&line).trim().to_string();
|
||||||
|
|
||||||
|
if text.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Dial response: {}", text);
|
||||||
|
|
||||||
|
// Skip command echo
|
||||||
|
if text.starts_with("ATDT") || text.starts_with("ATD") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error conditions
|
||||||
|
if text.contains("NO CARRIER") || text.contains("BUSY") {
|
||||||
|
return Err(FaxError::NoCarrier);
|
||||||
|
}
|
||||||
|
if text.contains("ERROR") {
|
||||||
|
return Err(FaxError::modem("Dial command failed"));
|
||||||
|
}
|
||||||
|
if text.contains("+FHNG:") {
|
||||||
|
return Err(FaxError::protocol(format!("Fax hangup: {}", text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection established
|
||||||
|
if text.contains("CONNECT") {
|
||||||
|
tracing::info!("Connected!");
|
||||||
|
// Read remaining session info (e.g. +FCON)
|
||||||
|
let extra = self.driver.read_all(2000)?;
|
||||||
|
let extra_text = String::from_utf8_lossy(&extra);
|
||||||
|
if !extra_text.trim().is_empty() {
|
||||||
|
tracing::info!("Session info: {}", extra_text);
|
||||||
|
if extra_text.contains("+FHNG:") {
|
||||||
|
return Err(FaxError::protocol(format!("Fax hangup: {}", extra_text.trim())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some modems send +FCON without CONNECT
|
||||||
|
if text.contains("+FCON") {
|
||||||
|
tracing::info!("Fax conference established");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Class 2: wait for fax session indicators
|
Err(FaxError::Timeout { ms: 60000 })
|
||||||
// CONNECT indicates fax session is ready
|
|
||||||
// The modem returns: CONNECT, +FCON: <params>
|
|
||||||
let extra = self.driver.read_all(5000)?;
|
|
||||||
let extra_text = String::from_utf8_lossy(&extra);
|
|
||||||
tracing::info!("Session info: {}", extra_text);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_page(&mut self, page: &Page) -> Result<()> {
|
fn send_page(&mut self, page: &Page) -> Result<()> {
|
||||||
@@ -120,13 +167,29 @@ impl<'a> Class2Send<'a> {
|
|||||||
self.driver.write_raw(b"AT+FDT\r")?;
|
self.driver.write_raw(b"AT+FDT\r")?;
|
||||||
self.driver.flush()?;
|
self.driver.flush()?;
|
||||||
|
|
||||||
// Wait for CONNECT
|
// Wait for CONNECT with longer timeout and retry logic
|
||||||
let response = self.driver.read_until(b"CONNECT\r\n", 10000)?;
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||||
let text = String::from_utf8_lossy(&response);
|
let mut got_connect = false;
|
||||||
tracing::debug!("FDT response: {}", text);
|
while std::time::Instant::now() < deadline {
|
||||||
|
let response = match self.driver.read_until(b"\r\n", 5000) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(crate::error::FaxError::Timeout { .. }) => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&response);
|
||||||
|
tracing::debug!("FDT response: {}", text);
|
||||||
|
|
||||||
if text.contains("ERROR") {
|
if text.contains("CONNECT") {
|
||||||
return Err(FaxError::modem("FDT command failed"));
|
got_connect = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if text.contains("ERROR") {
|
||||||
|
return Err(FaxError::modem("FDT command failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got_connect {
|
||||||
|
return Err(FaxError::Timeout { ms: 30000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode pixel data to T.4 MH format
|
// Encode pixel data to T.4 MH format
|
||||||
@@ -163,6 +226,10 @@ impl<'a> Class2Send<'a> {
|
|||||||
// Wait for confirmation
|
// Wait for confirmation
|
||||||
let response = self.driver.read_until(b"\r\n", 10000)?;
|
let response = self.driver.read_until(b"\r\n", 10000)?;
|
||||||
tracing::debug!("End page response: {}", String::from_utf8_lossy(&response));
|
tracing::debug!("End page response: {}", String::from_utf8_lossy(&response));
|
||||||
|
|
||||||
|
// Brief pause before next page
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
self.driver.drain()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-6
@@ -23,10 +23,13 @@ pub enum FrameType {
|
|||||||
impl FrameType {
|
impl FrameType {
|
||||||
pub fn from_control(ctrl: u8) -> Self {
|
pub fn from_control(ctrl: u8) -> Self {
|
||||||
match ctrl {
|
match ctrl {
|
||||||
0x08 => FrameType::Dis,
|
0x80 | 0x08 => FrameType::Dis, // DIS from called (0x80) or caller
|
||||||
0x28 => FrameType::Dcs,
|
0x28 => FrameType::Dcs, // DCS (Digital Command Signal)
|
||||||
0x41 => FrameType::Tcf,
|
0x21 => FrameType::Cfr, // CFR (Confirmation to Receive)
|
||||||
0x21 => FrameType::Cfr,
|
0x31 => FrameType::Other(0x31), // MCF (Message Confirmation)
|
||||||
|
0x41 => FrameType::Tcf, // TCF (Training Check)
|
||||||
|
0x5D | 0x42 => FrameType::Other(ctrl), // EOP / EOM
|
||||||
|
0x73 | 0x74 => FrameType::Other(ctrl), // DCN
|
||||||
_ => FrameType::Other(ctrl),
|
_ => FrameType::Other(ctrl),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,8 @@ pub fn parse_hdlc_frame(data: &[u8]) -> Result<HdlcFrame> {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::info!("HDLC raw frame: {:02X?}", frame_data);
|
||||||
|
|
||||||
if frame_data.len() < 4 {
|
if frame_data.len() < 4 {
|
||||||
return Err(FaxError::Hdlc(format!(
|
return Err(FaxError::Hdlc(format!(
|
||||||
"Frame too short: {} bytes",
|
"Frame too short: {} bytes",
|
||||||
@@ -80,13 +85,30 @@ pub fn parse_hdlc_frame(data: &[u8]) -> Result<HdlcFrame> {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// T.30 HDLC frame format:
|
||||||
|
// [Address] [HDLC Control] [FCF (Facsimile Control Field)] [Information] [FCS]
|
||||||
|
// FCF indicates frame type (DIS=0x80, DCS=0x28, CFR=0x21, etc.)
|
||||||
let fcs_bytes = &frame_data[frame_data.len() - 2..];
|
let fcs_bytes = &frame_data[frame_data.len() - 2..];
|
||||||
let fcs = u16::from_le_bytes([fcs_bytes[0], fcs_bytes[1]]);
|
let fcs = u16::from_le_bytes([fcs_bytes[0], fcs_bytes[1]]);
|
||||||
let information = frame_data[2..frame_data.len() - 2].to_vec();
|
|
||||||
|
// Use FCF (frame_data[2]) as the control field for T.30 purposes
|
||||||
|
// If frame has only 3 bytes before FCS, use frame_data[1] (HDLC control)
|
||||||
|
let control = if frame_data.len() > 4 {
|
||||||
|
frame_data[2] // T.30 FCF
|
||||||
|
} else {
|
||||||
|
frame_data[1] // HDLC control (fallback)
|
||||||
|
};
|
||||||
|
|
||||||
|
let information_start = if frame_data.len() > 4 { 3 } else { 2 };
|
||||||
|
let information = if information_start < frame_data.len() - 2 {
|
||||||
|
frame_data[information_start..frame_data.len() - 2].to_vec()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
Ok(HdlcFrame {
|
Ok(HdlcFrame {
|
||||||
address: frame_data[0],
|
address: frame_data[0],
|
||||||
control: frame_data[1],
|
control,
|
||||||
information,
|
information,
|
||||||
fcs,
|
fcs,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub mod error;
|
|||||||
pub mod modem;
|
pub mod modem;
|
||||||
pub mod fax;
|
pub mod fax;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
|
pub mod ocr;
|
||||||
|
pub mod preview;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
|||||||
+431
-5
@@ -45,6 +45,30 @@ enum Commands {
|
|||||||
/// Fax class (1 or 2). Class 2 is simpler but requires Class 2 capable modem.
|
/// Fax class (1 or 2). Class 2 is simpler but requires Class 2 capable modem.
|
||||||
#[arg(short = 'c', long, default_value = "2")]
|
#[arg(short = 'c', long, default_value = "2")]
|
||||||
class: String,
|
class: String,
|
||||||
|
/// Name of recipient (for cover page)
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Sender name (for cover page)
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Subject (for cover page)
|
||||||
|
#[arg(long)]
|
||||||
|
subject: Option<String>,
|
||||||
|
/// Notes (for cover page)
|
||||||
|
#[arg(long)]
|
||||||
|
note: Option<String>,
|
||||||
|
},
|
||||||
|
/// Receive a fax (Class 2 auto-answer)
|
||||||
|
Receive {
|
||||||
|
/// Serial device path
|
||||||
|
#[arg(short, long, default_value = "/dev/cu.usbmodem123456781")]
|
||||||
|
device: String,
|
||||||
|
/// Number of rings before auto-answer
|
||||||
|
#[arg(short, long, default_value = "2")]
|
||||||
|
rings: u8,
|
||||||
|
/// Output directory for received faxes
|
||||||
|
#[arg(short, long, default_value = "/tmp")]
|
||||||
|
output: String,
|
||||||
},
|
},
|
||||||
/// Interactively test the modem
|
/// Interactively test the modem
|
||||||
Test {
|
Test {
|
||||||
@@ -55,6 +79,91 @@ enum Commands {
|
|||||||
#[arg(short = '2', long)]
|
#[arg(short = '2', long)]
|
||||||
class2: bool,
|
class2: bool,
|
||||||
},
|
},
|
||||||
|
/// Generate a cover page file (TIFF or HTML)
|
||||||
|
CoverCreate {
|
||||||
|
/// Recipient name
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Sender name
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Subject
|
||||||
|
#[arg(long)]
|
||||||
|
subject: Option<String>,
|
||||||
|
/// Notes
|
||||||
|
#[arg(long)]
|
||||||
|
note: Option<String>,
|
||||||
|
/// Total pages
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
pages: u32,
|
||||||
|
/// Output file path
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<String>,
|
||||||
|
/// Output format: tif, html, or both
|
||||||
|
#[arg(long, default_value = "html")]
|
||||||
|
format: String,
|
||||||
|
},
|
||||||
|
/// Update cover page settings for a queued fax job
|
||||||
|
JobCover {
|
||||||
|
/// Job ID
|
||||||
|
job_id: String,
|
||||||
|
/// Recipient name
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Sender name
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Subject
|
||||||
|
#[arg(long)]
|
||||||
|
subject: Option<String>,
|
||||||
|
/// Notes
|
||||||
|
#[arg(long)]
|
||||||
|
note: Option<String>,
|
||||||
|
/// Path to SQLite queue database (omit to use API)
|
||||||
|
#[arg(long)]
|
||||||
|
db: Option<String>,
|
||||||
|
},
|
||||||
|
/// Generate interactive HTML preview of a fax page
|
||||||
|
Preview {
|
||||||
|
/// Input file (PDF, TIFF, or image). Omit to preview only the cover page.
|
||||||
|
input: Option<String>,
|
||||||
|
/// Output HTML file (default: preview.html)
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<String>,
|
||||||
|
/// Resolution (standard/fine/superfine)
|
||||||
|
#[arg(short, long, default_value = "fine")]
|
||||||
|
resolution: String,
|
||||||
|
/// Cover page recipient name
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Cover page sender name
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Cover page subject
|
||||||
|
#[arg(long)]
|
||||||
|
subject: Option<String>,
|
||||||
|
/// Cover page notes
|
||||||
|
#[arg(long)]
|
||||||
|
note: Option<String>,
|
||||||
|
},
|
||||||
|
/// Generate cover page and verify content via OCR
|
||||||
|
VerifyCover {
|
||||||
|
/// Recipient name
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Sender name
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Subject
|
||||||
|
#[arg(long)]
|
||||||
|
subject: Option<String>,
|
||||||
|
/// Notes
|
||||||
|
#[arg(long)]
|
||||||
|
note: Option<String>,
|
||||||
|
/// Total pages
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
pages: u32,
|
||||||
|
},
|
||||||
/// Convert PDF to TIFF-F fax format (dry test)
|
/// Convert PDF to TIFF-F fax format (dry test)
|
||||||
Convert {
|
Convert {
|
||||||
/// Input PDF file path
|
/// Input PDF file path
|
||||||
@@ -82,9 +191,18 @@ async fn main() -> Result<()> {
|
|||||||
match cli.command.unwrap_or(Commands::Serve { config: None }) {
|
match cli.command.unwrap_or(Commands::Serve { config: None }) {
|
||||||
Commands::Detect { device } => cmd_detect(&device),
|
Commands::Detect { device } => cmd_detect(&device),
|
||||||
Commands::Serve { config } => cmd_serve(config).await,
|
Commands::Serve { config } => cmd_serve(config).await,
|
||||||
Commands::Send { recipient, file, device, resolution, class } =>
|
Commands::Send { recipient, file, device, resolution, class, to, from, subject, note } =>
|
||||||
cmd_send(&device, &recipient, &file, &resolution, &class),
|
cmd_send(&device, &recipient, &file, &resolution, &class, to, from, subject, note),
|
||||||
|
Commands::Receive { device, rings, output } => cmd_receive(&device, rings, &output),
|
||||||
Commands::Test { device, class2 } => cmd_test(&device, class2),
|
Commands::Test { device, class2 } => cmd_test(&device, class2),
|
||||||
|
Commands::CoverCreate { to, from, subject, note, pages, output, format } =>
|
||||||
|
cmd_cover_create(to, from, subject, note, pages, output, &format),
|
||||||
|
Commands::JobCover { job_id, to, from, subject, note, db } =>
|
||||||
|
cmd_job_cover(&job_id, to, from, subject, note, db.as_deref()),
|
||||||
|
Commands::Preview { input, output, resolution, to, from, subject, note } =>
|
||||||
|
cmd_preview(input.as_deref(), output.as_deref(), &resolution, to, from, subject, note),
|
||||||
|
Commands::VerifyCover { to, from, subject, note, pages } =>
|
||||||
|
cmd_verify_cover(to, from, subject, note, pages),
|
||||||
Commands::Convert { input, output, resolution } => cmd_convert(&input, output.as_deref(), &resolution),
|
Commands::Convert { input, output, resolution } => cmd_convert(&input, output.as_deref(), &resolution),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +245,8 @@ async fn cmd_serve(config_path: Option<String>) -> Result<()> {
|
|||||||
telfax::api::start_server(config, queue.clone()).await
|
telfax::api::start_server(config, queue.clone()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: &str) -> Result<()> {
|
fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: &str,
|
||||||
|
to: Option<String>, from: Option<String>, subject: Option<String>, note: Option<String>) -> Result<()> {
|
||||||
info!("Sending fax to {} from {} using device {} (Class {})", recipient, file, device, class);
|
info!("Sending fax to {} from {} using device {} (Class {})", recipient, file, device, class);
|
||||||
|
|
||||||
let mut driver = ModemDriver::open(device, 115200)?;
|
let mut driver = ModemDriver::open(device, 115200)?;
|
||||||
@@ -140,7 +259,7 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class:
|
|||||||
};
|
};
|
||||||
|
|
||||||
let path = std::path::Path::new(file);
|
let path = std::path::Path::new(file);
|
||||||
let doc = if file.to_lowercase().ends_with(".pdf") {
|
let mut doc = if file.to_lowercase().ends_with(".pdf") {
|
||||||
info!("Converting PDF to fax format...");
|
info!("Converting PDF to fax format...");
|
||||||
telfax::document::pdf_to_fax_document(path, fax_resolution)?
|
telfax::document::pdf_to_fax_document(path, fax_resolution)?
|
||||||
} else {
|
} else {
|
||||||
@@ -149,8 +268,23 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class:
|
|||||||
telfax::document::document_from_image(&data)?
|
telfax::document::document_from_image(&data)?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optionally prepend cover page
|
||||||
|
if to.is_some() || from.is_some() || subject.is_some() || note.is_some() {
|
||||||
|
let total_pages = doc.pages.len() as u32 + 1;
|
||||||
|
let params = telfax::document::CoverParams {
|
||||||
|
to: to.unwrap_or_default(),
|
||||||
|
from: from.unwrap_or_default(),
|
||||||
|
subject: subject.unwrap_or_default(),
|
||||||
|
notes: note.unwrap_or_default(),
|
||||||
|
total_pages,
|
||||||
|
};
|
||||||
|
info!("Generating cover page...");
|
||||||
|
let cover_page = telfax::document::generate_cover_page(¶ms)?;
|
||||||
|
doc.pages.insert(0, cover_page);
|
||||||
|
}
|
||||||
|
|
||||||
let page = &doc.pages[0];
|
let page = &doc.pages[0];
|
||||||
info!("Document: {}x{} pixels, {} bytes", page.width_pels, page.rows, page.pixels.len());
|
info!("Document: {}x{} pixels, {} bytes, {} pages", page.width_pels, page.rows, page.pixels.len(), doc.pages.len());
|
||||||
|
|
||||||
// Choose fax class
|
// Choose fax class
|
||||||
match class {
|
match class {
|
||||||
@@ -176,6 +310,42 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class:
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_receive(device: &str, rings: u8, output_dir: &str) -> Result<()> {
|
||||||
|
info!("Setting up Class 2 receive on {} ({} rings)", device, rings);
|
||||||
|
|
||||||
|
let mut driver = ModemDriver::open(device, 115200)?;
|
||||||
|
|
||||||
|
let mut receiver = telfax::fax::class2::Class2Receive::new(&mut driver)
|
||||||
|
.with_station_id("TELFAX");
|
||||||
|
|
||||||
|
receiver.setup_auto_answer(rings)?;
|
||||||
|
|
||||||
|
info!("Waiting for incoming fax call...");
|
||||||
|
|
||||||
|
receiver.wait_for_call(300000)?;
|
||||||
|
|
||||||
|
info!("Call received, receiving pages...");
|
||||||
|
|
||||||
|
let pages = receiver.receive_pages()?;
|
||||||
|
|
||||||
|
info!("Received {} pages", pages.len());
|
||||||
|
|
||||||
|
// Save received pages as TIFF files
|
||||||
|
for (i, page_data) in pages.iter().enumerate() {
|
||||||
|
let filename = format!("fax_{:?}_page{}.tif", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(), i + 1);
|
||||||
|
let output_path = std::path::Path::new(output_dir).join(filename);
|
||||||
|
|
||||||
|
std::fs::write(&output_path, page_data)?;
|
||||||
|
|
||||||
|
info!("Page {} saved to {} ({} bytes)", i + 1, output_path.display(), page_data.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
receiver.end_session()?;
|
||||||
|
|
||||||
|
info!("Fax receive completed!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_test(device: &str, test_class2: bool) -> Result<()> {
|
fn cmd_test(device: &str, test_class2: bool) -> Result<()> {
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
@@ -242,6 +412,225 @@ fn cmd_test(device: &str, test_class2: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_preview(
|
||||||
|
input: Option<&str>,
|
||||||
|
output: Option<&str>,
|
||||||
|
resolution: &str,
|
||||||
|
to: Option<String>,
|
||||||
|
from: Option<String>,
|
||||||
|
subject: Option<String>,
|
||||||
|
note: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use telfax::document::cover::{generate_cover_page, CoverParams};
|
||||||
|
use telfax::document::tiff::TiffFaxWriter;
|
||||||
|
use telfax::preview;
|
||||||
|
|
||||||
|
let mut doc = if let Some(input_path) = input {
|
||||||
|
let fax_resolution = match resolution.to_lowercase().as_str() {
|
||||||
|
"standard" | "std" => telfax::config::FaxResolution::Standard,
|
||||||
|
"fine" => telfax::config::FaxResolution::Fine,
|
||||||
|
"superfine" | "super" => telfax::config::FaxResolution::SuperFine,
|
||||||
|
_ => telfax::config::FaxResolution::Fine,
|
||||||
|
};
|
||||||
|
let path = std::path::Path::new(input_path);
|
||||||
|
if input_path.to_lowercase().ends_with(".pdf") {
|
||||||
|
info!("Converting PDF to fax format...");
|
||||||
|
telfax::document::pdf_to_fax_document(path, fax_resolution)?
|
||||||
|
} else {
|
||||||
|
let data = std::fs::read(input_path)
|
||||||
|
.map_err(|e| telfax::error::FaxError::Other(format!("Failed to read file: {}", e)))?;
|
||||||
|
telfax::document::document_from_image(&data)?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
telfax::document::FaxDocument {
|
||||||
|
pages: Vec::new(),
|
||||||
|
format: telfax::document::DocumentFormat::Image,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if to.is_some() || from.is_some() || subject.is_some() || note.is_some() {
|
||||||
|
let total_pages = doc.pages.len() as u32 + 1;
|
||||||
|
let params = CoverParams {
|
||||||
|
to: to.unwrap_or_default(),
|
||||||
|
from: from.unwrap_or_default(),
|
||||||
|
subject: subject.unwrap_or_default(),
|
||||||
|
notes: note.unwrap_or_default(),
|
||||||
|
total_pages,
|
||||||
|
};
|
||||||
|
let cover_page = generate_cover_page(¶ms)?;
|
||||||
|
doc.pages.insert(0, cover_page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if doc.pages.is_empty() {
|
||||||
|
return Err(telfax::error::FaxError::Other(
|
||||||
|
"No content to preview. Provide an input file or cover page options.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_path = output
|
||||||
|
.map(|s| std::path::PathBuf::from(s))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("preview.html"));
|
||||||
|
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let tiff_path = std::env::temp_dir().join(format!("telfax_preview_{}.tif", stamp));
|
||||||
|
TiffFaxWriter::write_fax_tiff(&tiff_path, &doc.pages)?;
|
||||||
|
|
||||||
|
let first_page = &doc.pages[0];
|
||||||
|
let file_name = input.and_then(|p| std::path::Path::new(p).file_name())
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "cover".to_string());
|
||||||
|
let title = format!("{} — {} pages", file_name, doc.pages.len());
|
||||||
|
preview::generate_preview_html(first_page, &title, &output_path)?;
|
||||||
|
|
||||||
|
std::fs::remove_file(&tiff_path).ok();
|
||||||
|
|
||||||
|
println!("Preview saved to: {}", output_path.display());
|
||||||
|
println!("Open in your browser to view with pan and zoom.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_verify_cover(
|
||||||
|
to: Option<String>,
|
||||||
|
from: Option<String>,
|
||||||
|
subject: Option<String>,
|
||||||
|
note: Option<String>,
|
||||||
|
total_pages: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
use telfax::document::cover::{generate_cover_page, CoverParams};
|
||||||
|
use telfax::document::tiff::TiffFaxWriter;
|
||||||
|
use telfax::ocr::{self, verify_cover};
|
||||||
|
|
||||||
|
let params = CoverParams {
|
||||||
|
to: to.unwrap_or_default(),
|
||||||
|
from: from.unwrap_or_default(),
|
||||||
|
subject: subject.unwrap_or_default(),
|
||||||
|
notes: note.unwrap_or_default(),
|
||||||
|
total_pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("=== Cover Page OCR Verification ===");
|
||||||
|
println!("Params: TO={}, FROM={}, SUBJECT={}, NOTES={}, PAGES={}",
|
||||||
|
params.to, params.from, params.subject, params.notes, params.total_pages);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let page = generate_cover_page(¶ms)?;
|
||||||
|
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let tiff_path = std::env::temp_dir().join(format!("telfax_ocr_verify_{}.tif", stamp));
|
||||||
|
|
||||||
|
TiffFaxWriter::write_fax_tiff(&tiff_path, &[page])?;
|
||||||
|
println!("Cover page: {} ({} bytes)", tiff_path.display(),
|
||||||
|
std::fs::metadata(&tiff_path).map(|m| m.len()).unwrap_or(0));
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("Running tesseract OCR...");
|
||||||
|
let ocr_text = ocr::ocr_text(&tiff_path)?;
|
||||||
|
println!();
|
||||||
|
println!("=== OCR Output ===");
|
||||||
|
for line in ocr_text.lines() {
|
||||||
|
println!(" {}", line);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let result = verify_cover(&ocr_text, ¶ms)?;
|
||||||
|
println!("=== Field Verification ===");
|
||||||
|
|
||||||
|
let all_field_names = ["FACSIMILE", "TO", "FROM", "DATE", "PAGES", "SUBJECT", "NOTES"];
|
||||||
|
for name in &all_field_names {
|
||||||
|
let missing = result.missing.iter().any(|m| m.starts_with(name) || m == name);
|
||||||
|
println!(" {} {}", if !missing { "✅" } else { "❌" }, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if result.all_fields_present {
|
||||||
|
println!("✅ All fields verified successfully!");
|
||||||
|
} else {
|
||||||
|
println!("❌ Missing fields: {}", result.missing.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(&tiff_path).ok();
|
||||||
|
println!();
|
||||||
|
println!("=== Verification Complete ===");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_cover_create(
|
||||||
|
to: Option<String>,
|
||||||
|
from: Option<String>,
|
||||||
|
subject: Option<String>,
|
||||||
|
note: Option<String>,
|
||||||
|
total_pages: u32,
|
||||||
|
output: Option<String>,
|
||||||
|
format: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
use telfax::document::cover::{generate_cover_page, CoverParams};
|
||||||
|
use telfax::document::tiff::TiffFaxWriter;
|
||||||
|
use telfax::preview;
|
||||||
|
|
||||||
|
let params = CoverParams {
|
||||||
|
to: to.unwrap_or_default(),
|
||||||
|
from: from.unwrap_or_default(),
|
||||||
|
subject: subject.unwrap_or_default(),
|
||||||
|
notes: note.unwrap_or_default(),
|
||||||
|
total_pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("=== Generate Cover Page ===");
|
||||||
|
println!("TO: {}, FROM: {}, SUBJECT: {}, NOTES: {}, PAGES: {}",
|
||||||
|
params.to, params.from, params.subject, params.notes, params.total_pages);
|
||||||
|
|
||||||
|
let page = generate_cover_page(¶ms)?;
|
||||||
|
println!(" Page: {}x{} pixels", page.width_pels, page.rows);
|
||||||
|
|
||||||
|
let output_path = output
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("cover"));
|
||||||
|
|
||||||
|
match format {
|
||||||
|
"tif" | "tiff" => {
|
||||||
|
let path = output_path.with_extension("tif");
|
||||||
|
TiffFaxWriter::write_fax_tiff(&path, &[page])?;
|
||||||
|
println!(" TIFF saved: {} ({} bytes)", path.display(),
|
||||||
|
std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0));
|
||||||
|
}
|
||||||
|
"html" => {
|
||||||
|
let path = output_path.with_extension("html");
|
||||||
|
let title = format!("Cover — {} pages", params.total_pages);
|
||||||
|
preview::generate_preview_html(&page, &title, &path)?;
|
||||||
|
println!(" HTML saved: {}", path.display());
|
||||||
|
}
|
||||||
|
"both" => {
|
||||||
|
let tiff_path = output_path.with_extension("tif");
|
||||||
|
TiffFaxWriter::write_fax_tiff(&tiff_path, &[page.clone()])?;
|
||||||
|
println!(" TIFF saved: {} ({} bytes)", tiff_path.display(),
|
||||||
|
std::fs::metadata(&tiff_path).map(|m| m.len()).unwrap_or(0));
|
||||||
|
|
||||||
|
let html_path = output_path.with_extension("html");
|
||||||
|
let title = format!("Cover — {} pages", params.total_pages);
|
||||||
|
preview::generate_preview_html(&page, &title, &html_path)?;
|
||||||
|
println!(" HTML saved: {}", html_path.display());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(telfax::error::FaxError::Other(format!(
|
||||||
|
"Unknown format: {}. Use tif, html, or both.", format
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("=== Done ===");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_convert(input: &str, output: Option<&str>, resolution: &str) -> Result<()> {
|
fn cmd_convert(input: &str, output: Option<&str>, resolution: &str) -> Result<()> {
|
||||||
let fax_resolution = match resolution.to_lowercase().as_str() {
|
let fax_resolution = match resolution.to_lowercase().as_str() {
|
||||||
"standard" | "std" => FaxResolution::Standard,
|
"standard" | "std" => FaxResolution::Standard,
|
||||||
@@ -313,3 +702,40 @@ fn cmd_convert(input: &str, output: Option<&str>, resolution: &str) -> Result<()
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_job_cover(
|
||||||
|
job_id: &str,
|
||||||
|
to: Option<String>,
|
||||||
|
from: Option<String>,
|
||||||
|
subject: Option<String>,
|
||||||
|
note: Option<String>,
|
||||||
|
db_path: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
let job_id = uuid::Uuid::from_str(job_id)
|
||||||
|
.map_err(|e| telfax::error::FaxError::Other(format!("Invalid job ID: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(db) = db_path {
|
||||||
|
let mut queue = telfax::queue::FaxQueue::new_sqlite(std::path::Path::new(db))?;
|
||||||
|
let found = queue.update_cover(&job_id, to.clone(), from.clone(), subject.clone(), note.clone())?;
|
||||||
|
if found {
|
||||||
|
println!("✅ Cover updated for job {}", job_id);
|
||||||
|
} else {
|
||||||
|
return Err(telfax::error::FaxError::Other(format!("Job {} not found", job_id)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(telfax::error::FaxError::Other(
|
||||||
|
"No database path specified. Use --db <path> to modify a queued job's cover page, \
|
||||||
|
or use the API endpoint PUT /api/fax/jobs/{id}/cover".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" TO: {}", to.as_deref().unwrap_or("(unchanged)"));
|
||||||
|
println!(" FROM: {}", from.as_deref().unwrap_or("(unchanged)"));
|
||||||
|
println!(" SUBJECT: {}", subject.as_deref().unwrap_or("(unchanged)"));
|
||||||
|
println!(" NOTES: {}", note.as_deref().unwrap_or("(unchanged)"));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ impl ModemDriver {
|
|||||||
self.port.read_exact(buf).map_err(Into::into)
|
self.port.read_exact(buf).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_byte(&mut self, timeout_ms: u64) -> Result<u8> {
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
|
||||||
|
let mut single = [0u8; 1];
|
||||||
|
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
match self.port.read(&mut single) {
|
||||||
|
Ok(0) => continue,
|
||||||
|
Ok(_) => return Ok(single[0]),
|
||||||
|
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_until(&mut self, pattern: &[u8], timeout_ms: u64) -> Result<Vec<u8>> {
|
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 deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
|
||||||
let mut buffer = Vec::new();
|
let mut buffer = Vec::new();
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
use crate::document::CoverParams;
|
||||||
|
use crate::error::{FaxError, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub struct OcrResult {
|
||||||
|
pub all_fields_present: bool,
|
||||||
|
pub missing: Vec<String>,
|
||||||
|
pub raw_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ocr_text(tiff_path: &Path) -> Result<String> {
|
||||||
|
let output = Command::new("tesseract")
|
||||||
|
.arg(tiff_path.to_str().unwrap())
|
||||||
|
.arg("stdout")
|
||||||
|
.arg("-l")
|
||||||
|
.arg("eng")
|
||||||
|
.arg("--psm")
|
||||||
|
.arg("6")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| FaxError::Other(format!("Failed to run tesseract: {}", e)))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(FaxError::Other(format!("tesseract error: {}", stderr)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_cover(text: &str, params: &CoverParams) -> Result<OcrResult> {
|
||||||
|
let mut missing: Vec<String> = Vec::new();
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
|
||||||
|
if !contains_any(text, &["FACSIMILE"]) {
|
||||||
|
missing.push("FACSIMILE header".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.to.is_empty() {
|
||||||
|
let found = if is_ascii_text(¶ms.to) {
|
||||||
|
let needle = format!("TO:{}", params.to);
|
||||||
|
let needle2 = format!("TO: {}", params.to);
|
||||||
|
lines.iter().any(|l| {
|
||||||
|
let trimmed = l.trim();
|
||||||
|
trimmed == needle.as_str() || trimmed == needle2.as_str() || trimmed.contains(¶ms.to)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contains_any(text, &["TO:"])
|
||||||
|
};
|
||||||
|
if !found {
|
||||||
|
missing.push(format!("TO: {}", params.to));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !contains_any(text, &["TO:"]) {
|
||||||
|
missing.push("TO:".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.from.is_empty() {
|
||||||
|
let found = if is_ascii_text(¶ms.from) {
|
||||||
|
let needle = format!("FROM:{}", params.from);
|
||||||
|
let needle2 = format!("FROM: {}", params.from);
|
||||||
|
lines.iter().any(|l| {
|
||||||
|
let trimmed = l.trim();
|
||||||
|
trimmed == needle.as_str() || trimmed == needle2.as_str() || trimmed.contains(¶ms.from)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contains_any(text, &["FROM:"])
|
||||||
|
};
|
||||||
|
if !found {
|
||||||
|
missing.push(format!("FROM: {}", params.from));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !contains_any(text, &["FROM:"]) {
|
||||||
|
missing.push("FROM:".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contains_pattern_like_date(text) {
|
||||||
|
if !contains_any(text, &["DATE:"]) {
|
||||||
|
missing.push("DATE".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pages_str = params.total_pages.to_string();
|
||||||
|
let found_pages = lines.iter().any(|l| l.contains("PAGES:") && l.contains(&pages_str));
|
||||||
|
if !found_pages {
|
||||||
|
if !contains_any(text, &["PAGES:"]) {
|
||||||
|
missing.push(format!("PAGES: {}", params.total_pages));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.subject.is_empty() {
|
||||||
|
let found = if is_ascii_text(¶ms.subject) {
|
||||||
|
lines.iter().any(|l| {
|
||||||
|
let trimmed = l.trim();
|
||||||
|
(trimmed.starts_with("SUBJECT") || trimmed.starts_with("SUBJECT:"))
|
||||||
|
&& trimmed.contains(¶ms.subject)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contains_any(text, &["SUBJECT"])
|
||||||
|
};
|
||||||
|
if !found {
|
||||||
|
if !contains_any(text, &["SUBJECT"]) {
|
||||||
|
missing.push(format!("SUBJECT: {}", params.subject));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.notes.is_empty() {
|
||||||
|
let note_line = params.notes.lines().next().unwrap_or("");
|
||||||
|
let found = if is_ascii_text(note_line) {
|
||||||
|
lines.iter().any(|l| {
|
||||||
|
let trimmed = l.trim();
|
||||||
|
trimmed.contains(note_line) || (trimmed.starts_with("NOTES") && trimmed.len() > 6)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contains_any(text, &["NOTES"])
|
||||||
|
};
|
||||||
|
if !found {
|
||||||
|
if !contains_any(text, &["NOTES"]) {
|
||||||
|
missing.push(format!("NOTES: {}", params.notes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(OcrResult {
|
||||||
|
all_fields_present: missing.is_empty(),
|
||||||
|
missing,
|
||||||
|
raw_text: text.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_any(text: &str, patterns: &[&str]) -> bool {
|
||||||
|
patterns.iter().any(|p| text.contains(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_pattern_like_date(text: &str) -> bool {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let len = bytes.len();
|
||||||
|
if len < 10 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for i in 0..=len.saturating_sub(10) {
|
||||||
|
if bytes[i].is_ascii_digit()
|
||||||
|
&& bytes[i + 1].is_ascii_digit()
|
||||||
|
&& bytes[i + 2].is_ascii_digit()
|
||||||
|
&& bytes[i + 3].is_ascii_digit()
|
||||||
|
&& bytes[i + 4] == b'/'
|
||||||
|
&& bytes[i + 5].is_ascii_digit()
|
||||||
|
&& bytes[i + 6].is_ascii_digit()
|
||||||
|
&& bytes[i + 7] == b'/'
|
||||||
|
&& bytes[i + 8].is_ascii_digit()
|
||||||
|
&& bytes[i + 9].is_ascii_digit()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ascii_text(s: &str) -> bool {
|
||||||
|
s.chars().all(|c| c.is_ascii())
|
||||||
|
}
|
||||||
+315
@@ -0,0 +1,315 @@
|
|||||||
|
use crate::document::convert::Page;
|
||||||
|
use crate::error::Result;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
pub fn generate_preview_html(page: &Page, title: &str, output_path: &std::path::Path) -> Result<()> {
|
||||||
|
let png_data = fax_page_to_png_bytes(page)?;
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
|
||||||
|
let data_uri = format!("data:image/png;base64,{}", b64);
|
||||||
|
|
||||||
|
let w = page.width_pels;
|
||||||
|
let h = page.rows;
|
||||||
|
let html = build_viewer_html(&data_uri, w, h, title);
|
||||||
|
|
||||||
|
std::fs::write(output_path, html)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fax_page_to_png_bytes(page: &Page) -> Result<Vec<u8>> {
|
||||||
|
let width = page.width_pels as usize;
|
||||||
|
let height = page.rows as usize;
|
||||||
|
let bytes_per_row = (width + 7) / 8;
|
||||||
|
|
||||||
|
let mut img_data = Vec::with_capacity(width * height);
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let byte_idx = y * bytes_per_row + x / 8;
|
||||||
|
let bit = (page.pixels[byte_idx] >> (7 - (x % 8))) & 1;
|
||||||
|
img_data.push(if bit == 1 { 0u8 } else { 255u8 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut png_bytes = Vec::new();
|
||||||
|
{
|
||||||
|
use image::ImageEncoder;
|
||||||
|
let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
|
||||||
|
encoder
|
||||||
|
.write_image(&img_data, page.width_pels, page.rows, image::ExtendedColorType::L8)
|
||||||
|
.map_err(|e| crate::error::FaxError::Other(format!("PNG encode error: {}", e)))?;
|
||||||
|
}
|
||||||
|
Ok(png_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_viewer_html(data_uri: &str, width: u32, height: u32, title: &str) -> String {
|
||||||
|
let html = format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Fax Preview — {title}</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
body {{
|
||||||
|
background: #2a2a2a; color: #ccc; font-family: system-ui, sans-serif;
|
||||||
|
overflow: hidden; height: 100vh; display: flex; flex-direction: column;
|
||||||
|
}}
|
||||||
|
#toolbar {{
|
||||||
|
display: flex; align-items: center; gap: 12px; padding: 8px 16px;
|
||||||
|
background: #3a3a3a; border-bottom: 1px solid #555; user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}}
|
||||||
|
#toolbar .title {{ font-weight: 600; color: #fff; margin-right: auto; }}
|
||||||
|
#toolbar .info {{ font-size: 13px; color: #aaa; }}
|
||||||
|
#toolbar button {{
|
||||||
|
background: #555; border: none; color: #fff; padding: 4px 12px;
|
||||||
|
border-radius: 4px; cursor: pointer; font-size: 13px;
|
||||||
|
}}
|
||||||
|
#toolbar button:hover {{ background: #666; }}
|
||||||
|
#toolbar input[type=range] {{ width: 120px; }}
|
||||||
|
#canvas-wrap {{
|
||||||
|
flex: 1; overflow: hidden; position: relative; cursor: grab;
|
||||||
|
background: #1a1a1a;
|
||||||
|
background-image: radial-gradient(circle, #333 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}}
|
||||||
|
#canvas-wrap:active {{ cursor: grabbing; }}
|
||||||
|
#canvas-wrap canvas {{ display: block; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="toolbar">
|
||||||
|
<span class="title">{title}</span>
|
||||||
|
<span class="info" id="info">{width}×{height} px</span>
|
||||||
|
<button id="fit-btn">Fit</button>
|
||||||
|
<button id="reset-btn">Reset</button>
|
||||||
|
<label>Zoom: <span id="zoom-label">100</span>%</label>
|
||||||
|
<input type="range" id="zoom-slider" min="10" max="400" value="100" step="5">
|
||||||
|
</div>
|
||||||
|
<div id="canvas-wrap">
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const img = new Image();
|
||||||
|
const dataUri = {data_uri_str};
|
||||||
|
img.onload = init;
|
||||||
|
img.src = dataUri;
|
||||||
|
|
||||||
|
const PAGE_W = {width}, PAGE_H = {height};
|
||||||
|
const BORDER = 16;
|
||||||
|
const MARK_LEN = 20;
|
||||||
|
|
||||||
|
let scale = 1, offsetX = 0, offsetY = 0;
|
||||||
|
let dragStartX = 0, dragStartY = 0, dragOffX = 0, dragOffY = 0, dragging = false;
|
||||||
|
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const wrap = document.getElementById('canvas-wrap');
|
||||||
|
const zoomSlider = document.getElementById('zoom-slider');
|
||||||
|
const zoomLabel = document.getElementById('zoom-label');
|
||||||
|
|
||||||
|
function resize() {{
|
||||||
|
canvas.width = wrap.clientWidth;
|
||||||
|
canvas.height = wrap.clientHeight;
|
||||||
|
draw();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function draw() {{
|
||||||
|
const cw = canvas.width, ch = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
|
|
||||||
|
const pw = PAGE_W * scale;
|
||||||
|
const ph = PAGE_H * scale;
|
||||||
|
const ox = offsetX + (cw - pw) / 2;
|
||||||
|
const oy = offsetY + (ch - ph) / 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.fillRect(0, 0, cw, ch);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(ox, oy);
|
||||||
|
|
||||||
|
// shadow
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.shadowOffsetX = 0;
|
||||||
|
ctx.shadowOffsetY = 4;
|
||||||
|
|
||||||
|
// page background
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fillRect(-BORDER, -BORDER, pw + BORDER*2, ph + BORDER*2);
|
||||||
|
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// page image
|
||||||
|
if (img.complete) {{
|
||||||
|
ctx.drawImage(img, 0, 0, pw, ph);
|
||||||
|
}}
|
||||||
|
|
||||||
|
// boundary: thick border
|
||||||
|
ctx.strokeStyle = '#e53935';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeRect(0, 0, pw, ph);
|
||||||
|
|
||||||
|
// corner crop marks (outer)
|
||||||
|
const cm = MARK_LEN * scale;
|
||||||
|
ctx.strokeStyle = '#e53935';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
// top-left
|
||||||
|
ctx.moveTo(-BORDER*0.5, -cm); ctx.lineTo(-BORDER*0.5, -BORDER*0.5); ctx.lineTo(-cm, -BORDER*0.5);
|
||||||
|
// top-right
|
||||||
|
ctx.moveTo(pw + BORDER*0.5, -cm); ctx.lineTo(pw + BORDER*0.5, -BORDER*0.5); ctx.lineTo(pw + cm, -BORDER*0.5);
|
||||||
|
// bottom-left
|
||||||
|
ctx.moveTo(-BORDER*0.5, ph + cm); ctx.lineTo(-BORDER*0.5, ph + BORDER*0.5); ctx.lineTo(-cm, ph + BORDER*0.5);
|
||||||
|
// bottom-right
|
||||||
|
ctx.moveTo(pw + BORDER*0.5, ph + cm); ctx.lineTo(pw + BORDER*0.5, ph + BORDER*0.5); ctx.lineTo(pw + cm, ph + BORDER*0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// alignment marks at midpoints
|
||||||
|
ctx.strokeStyle = 'rgba(229, 57, 53, 0.4)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
// top
|
||||||
|
ctx.moveTo(pw/2, -MARK_LEN*0.5*scale); ctx.lineTo(pw/2, -BORDER*0.5);
|
||||||
|
// bottom
|
||||||
|
ctx.moveTo(pw/2, ph + MARK_LEN*0.5*scale); ctx.lineTo(pw/2, ph + BORDER*0.5);
|
||||||
|
// left
|
||||||
|
ctx.moveTo(-MARK_LEN*0.5*scale, ph/2); ctx.lineTo(-BORDER*0.5, ph/2);
|
||||||
|
// right
|
||||||
|
ctx.moveTo(pw + MARK_LEN*0.5*scale, ph/2); ctx.lineTo(pw + BORDER*0.5, ph/2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// DPI scale bar (bottom-right)
|
||||||
|
const dpi = 204;
|
||||||
|
const inchPx = dpi * scale;
|
||||||
|
const barInches = 2;
|
||||||
|
const barLen = inchPx * barInches;
|
||||||
|
const barX = pw - 40*scale - barLen;
|
||||||
|
const barY = ph - 30*scale;
|
||||||
|
ctx.strokeStyle = '#666';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(barX, barY); ctx.lineTo(barX + barLen, barY);
|
||||||
|
ctx.moveTo(barX, barY - 4*scale); ctx.lineTo(barX, barY + 4*scale);
|
||||||
|
ctx.moveTo(barX + barLen, barY - 4*scale); ctx.lineTo(barX + barLen, barY + 4*scale);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.font = `${{10*scale}}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(`${{barInches}} in (@ ${{dpi}} DPI)`, barX + barLen/2, barY + 4*scale);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// overlay info
|
||||||
|
const zoomPct = Math.round(scale * 100);
|
||||||
|
document.getElementById('zoom-label').textContent = zoomPct;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// mouse pan
|
||||||
|
canvas.addEventListener('mousedown', (e) => {{
|
||||||
|
dragging = true;
|
||||||
|
dragStartX = e.clientX;
|
||||||
|
dragStartY = e.clientY;
|
||||||
|
dragOffX = offsetX;
|
||||||
|
dragOffY = offsetY;
|
||||||
|
}});
|
||||||
|
window.addEventListener('mousemove', (e) => {{
|
||||||
|
if (!dragging) return;
|
||||||
|
offsetX = dragOffX + (e.clientX - dragStartX);
|
||||||
|
offsetY = dragOffY + (e.clientY - dragStartY);
|
||||||
|
draw();
|
||||||
|
}});
|
||||||
|
window.addEventListener('mouseup', () => {{ dragging = false; }});
|
||||||
|
|
||||||
|
// touch pan
|
||||||
|
canvas.addEventListener('touchstart', (e) => {{
|
||||||
|
if (e.touches.length === 1) {{
|
||||||
|
dragging = true;
|
||||||
|
dragStartX = e.touches[0].clientX;
|
||||||
|
dragStartY = e.touches[0].clientY;
|
||||||
|
dragOffX = offsetX;
|
||||||
|
dragOffY = offsetY;
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
canvas.addEventListener('touchmove', (e) => {{
|
||||||
|
if (!dragging || e.touches.length !== 1) return;
|
||||||
|
e.preventDefault();
|
||||||
|
offsetX = dragOffX + (e.touches[0].clientX - dragStartX);
|
||||||
|
offsetY = dragOffY + (e.touches[0].clientY - dragStartY);
|
||||||
|
draw();
|
||||||
|
}});
|
||||||
|
canvas.addEventListener('touchend', () => {{ dragging = false; }});
|
||||||
|
|
||||||
|
// zoom with wheel
|
||||||
|
canvas.addEventListener('wheel', (e) => {{
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
const cw = canvas.width, ch = canvas.height;
|
||||||
|
const ox = offsetX + (cw - PAGE_W * scale) / 2;
|
||||||
|
const oy = offsetY + (ch - PAGE_H * scale) / 2;
|
||||||
|
const px = (mx - ox) / scale;
|
||||||
|
const py = (my - oy) / scale;
|
||||||
|
|
||||||
|
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||||
|
const newScale = Math.min(4, Math.max(0.1, scale * factor));
|
||||||
|
scale = newScale;
|
||||||
|
offsetX = mx - px * scale - (cw - PAGE_W * scale) / 2;
|
||||||
|
offsetY = my - py * scale - (ch - PAGE_H * scale) / 2;
|
||||||
|
zoomSlider.value = Math.round(scale * 100);
|
||||||
|
draw();
|
||||||
|
}});
|
||||||
|
|
||||||
|
// zoom slider
|
||||||
|
zoomSlider.addEventListener('input', () => {{
|
||||||
|
const newScale = parseInt(zoomSlider.value) / 100;
|
||||||
|
const cw = canvas.width, ch = canvas.height;
|
||||||
|
const cx = cw / 2, cy = ch / 2;
|
||||||
|
const ox = offsetX + (cw - PAGE_W * scale) / 2;
|
||||||
|
const oy = offsetY + (ch - PAGE_H * scale) / 2;
|
||||||
|
const px = (cx - ox) / scale;
|
||||||
|
const py = (cy - oy) / scale;
|
||||||
|
scale = newScale;
|
||||||
|
offsetX = cx - px * scale - (cw - PAGE_W * scale) / 2;
|
||||||
|
offsetY = cy - py * scale - (ch - PAGE_H * scale) / 2;
|
||||||
|
draw();
|
||||||
|
}});
|
||||||
|
|
||||||
|
document.getElementById('fit-btn').addEventListener('click', () => {{
|
||||||
|
const cw = canvas.width, ch = canvas.height;
|
||||||
|
const s = Math.min((cw - 80) / PAGE_W, (ch - 80) / PAGE_H);
|
||||||
|
scale = s;
|
||||||
|
offsetX = 0; offsetY = 0;
|
||||||
|
zoomSlider.value = Math.round(scale * 100);
|
||||||
|
draw();
|
||||||
|
}});
|
||||||
|
|
||||||
|
document.getElementById('reset-btn').addEventListener('click', () => {{
|
||||||
|
scale = 1; offsetX = 0; offsetY = 0;
|
||||||
|
zoomSlider.value = 100;
|
||||||
|
draw();
|
||||||
|
}});
|
||||||
|
|
||||||
|
function init() {{
|
||||||
|
resize();
|
||||||
|
fitBtn = document.getElementById('fit-btn');
|
||||||
|
fitBtn.click();
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
title = title,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
data_uri_str = serde_json::to_string(data_uri).unwrap(),
|
||||||
|
);
|
||||||
|
html
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ pub struct FaxJob {
|
|||||||
pub retries: u8,
|
pub retries: u8,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub cover_to: Option<String>,
|
||||||
|
pub cover_from: Option<String>,
|
||||||
|
pub cover_subject: Option<String>,
|
||||||
|
pub cover_notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FaxJob {
|
impl FaxJob {
|
||||||
@@ -38,6 +42,10 @@ impl FaxJob {
|
|||||||
retries: 0,
|
retries: 0,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
cover_to: None,
|
||||||
|
cover_from: None,
|
||||||
|
cover_subject: None,
|
||||||
|
cover_notes: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-3
@@ -40,7 +40,11 @@ impl FaxQueue {
|
|||||||
pages INTEGER DEFAULT 0,
|
pages INTEGER DEFAULT 0,
|
||||||
retries INTEGER DEFAULT 0,
|
retries INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL,
|
||||||
|
cover_to TEXT,
|
||||||
|
cover_from TEXT,
|
||||||
|
cover_subject TEXT,
|
||||||
|
cover_notes TEXT
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
@@ -56,8 +60,8 @@ impl FaxQueue {
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
FaxQueue::Sqlite(s) => {
|
FaxQueue::Sqlite(s) => {
|
||||||
s.conn.execute(
|
s.conn.execute(
|
||||||
"INSERT INTO fax_jobs (id, recipient, document_path, status, pages, retries, created_at, updated_at)
|
"INSERT INTO fax_jobs (id, recipient, document_path, status, pages, retries, created_at, updated_at, cover_to, cover_from, cover_subject, cover_notes)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
job.id.to_string(),
|
job.id.to_string(),
|
||||||
job.recipient,
|
job.recipient,
|
||||||
@@ -67,6 +71,10 @@ impl FaxQueue {
|
|||||||
job.retries,
|
job.retries,
|
||||||
job.created_at.to_rfc3339(),
|
job.created_at.to_rfc3339(),
|
||||||
job.updated_at.to_rfc3339(),
|
job.updated_at.to_rfc3339(),
|
||||||
|
job.cover_to,
|
||||||
|
job.cover_from,
|
||||||
|
job.cover_subject,
|
||||||
|
job.cover_notes,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -106,6 +114,37 @@ impl FaxQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_cover(&mut self, id: &JobId, to: Option<String>, from: Option<String>, subject: Option<String>, notes: Option<String>) -> Result<bool> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
match self {
|
||||||
|
FaxQueue::InMemory(s) => {
|
||||||
|
if let Some(job) = s.jobs.get_mut(id) {
|
||||||
|
if to.is_some() { job.cover_to = to; }
|
||||||
|
if from.is_some() { job.cover_from = from; }
|
||||||
|
if subject.is_some() { job.cover_subject = subject; }
|
||||||
|
if notes.is_some() { job.cover_notes = notes; }
|
||||||
|
job.updated_at = now;
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
FaxQueue::Sqlite(s) => {
|
||||||
|
let affected = s.conn.execute(
|
||||||
|
"UPDATE fax_jobs SET cover_to = COALESCE(?1, cover_to),
|
||||||
|
cover_from = COALESCE(?2, cover_from),
|
||||||
|
cover_subject = COALESCE(?3, cover_subject),
|
||||||
|
cover_notes = COALESCE(?4, cover_notes),
|
||||||
|
updated_at = ?5
|
||||||
|
WHERE id = ?6",
|
||||||
|
rusqlite::params![to, from, subject, notes, now.to_rfc3339(), id.to_string()],
|
||||||
|
)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pending_jobs(&self) -> Vec<FaxJob> {
|
pub fn pending_jobs(&self) -> Vec<FaxJob> {
|
||||||
match self {
|
match self {
|
||||||
FaxQueue::InMemory(s) => s
|
FaxQueue::InMemory(s) => s
|
||||||
|
|||||||
Executable
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Class 1 Fax Receiver - for testing with USR sender"""
|
||||||
|
import termios
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: class1_receive.py <device>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
device = sys.argv[1]
|
||||||
|
fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
|
||||||
|
attrs = termios.tcgetattr(fd)
|
||||||
|
attrs[4] = termios.B115200
|
||||||
|
attrs[5] = termios.B115200
|
||||||
|
attrs[2] |= termios.CLOCAL | termios.CREAD
|
||||||
|
attrs[3] &= ~termios.ICANON
|
||||||
|
attrs[3] &= ~termios.ECHO
|
||||||
|
attrs[3] &= ~termios.ECHOE
|
||||||
|
attrs[3] &= ~termios.ISIG
|
||||||
|
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
||||||
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
||||||
|
|
||||||
|
def drain():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
os.read(fd, 4096)
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send_cmd(cmd, wait=0.5):
|
||||||
|
drain()
|
||||||
|
os.write(fd, cmd.encode() + b"\r")
|
||||||
|
time.sleep(wait)
|
||||||
|
result = b""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = os.read(fd, 4096)
|
||||||
|
result += data
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
return result.decode(errors="replace")
|
||||||
|
|
||||||
|
def wait_for(pattern, timeout=30):
|
||||||
|
start = time.time()
|
||||||
|
result = b""
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
try:
|
||||||
|
data = os.read(fd, 4096)
|
||||||
|
result += data
|
||||||
|
decoded = result.decode(errors="replace")
|
||||||
|
for p in pattern if isinstance(pattern, list) else [pattern]:
|
||||||
|
if p in decoded:
|
||||||
|
return decoded
|
||||||
|
except BlockingIOError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return result.decode(errors="replace")
|
||||||
|
|
||||||
|
print("[RECV] Initializing Class 1...")
|
||||||
|
send_cmd("AT", 0.3)
|
||||||
|
send_cmd("AT+FCLASS=1", 0.3)
|
||||||
|
|
||||||
|
print("[RECV] Waiting for rings...")
|
||||||
|
|
||||||
|
ring_count = 0
|
||||||
|
while ring_count < 2:
|
||||||
|
try:
|
||||||
|
data = os.read(fd, 4096)
|
||||||
|
text = data.decode(errors="replace")
|
||||||
|
if "RING" in text:
|
||||||
|
ring_count += text.count("RING")
|
||||||
|
print(f"[RECV] Ring {ring_count}")
|
||||||
|
except BlockingIOError:
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
print("[RECV] Answering...")
|
||||||
|
answer_result = send_cmd("ATA", 3)
|
||||||
|
print(f"[RECV] ATA result: {answer_result.strip()}")
|
||||||
|
|
||||||
|
if "CONNECT" not in answer_result:
|
||||||
|
drain()
|
||||||
|
os.write(fd, b"ATA\r")
|
||||||
|
wait_result = wait_for("CONNECT", 10)
|
||||||
|
print(f"[RECV] Wait result: {wait_result.strip()}")
|
||||||
|
|
||||||
|
print("[RECV] Connected! Sending CED tone...")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Send CED tone (2100Hz for 2.6-4 seconds)
|
||||||
|
ced_result = send_cmd("AT+FTS=8", 4) # FTS=8 sends 2100Hz
|
||||||
|
print(f"[RECV] CED result: {ced_result.strip()}")
|
||||||
|
|
||||||
|
# Wait 75ms then send DIS
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
print("[RECV] Sending DIS frame...")
|
||||||
|
|
||||||
|
# Start HDLC transmit
|
||||||
|
os.write(fd, b"AT+FTH=3\r")
|
||||||
|
fth_result = wait_for("CONNECT", 5)
|
||||||
|
print(f"[RECV] FTH=3: {fth_result.strip()}")
|
||||||
|
|
||||||
|
if "CONNECT" in fth_result:
|
||||||
|
# DIS frame: preamble+flags+DCS+data+RTC
|
||||||
|
# Address: 0xFF (broadcast), Control: 0x80 (DIS from called)
|
||||||
|
# DIS data: minimum capabilities
|
||||||
|
dis_data = bytes([
|
||||||
|
0xFF, 0x13, 0x80, # Address + Control (DIS from called)
|
||||||
|
0x00, 0x50, 0x1F, # DIS parameters
|
||||||
|
])
|
||||||
|
# Add FCS (we'll let modem calculate)
|
||||||
|
os.write(fd, dis_data)
|
||||||
|
os.write(fd, b"\x00\x00\x00") # Padding
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Wait for OK
|
||||||
|
dis_ok = wait_for("OK", 5)
|
||||||
|
print(f"[RECV] DIS sent: {dis_ok.strip()}")
|
||||||
|
|
||||||
|
print("[RECV] Waiting for DCS...")
|
||||||
|
drain()
|
||||||
|
os.write(fd, b"AT+FRH=3\r")
|
||||||
|
dcs_result = wait_for("OK", 10)
|
||||||
|
print(f"[RECV] DCS received: {len(dcs_result)} bytes")
|
||||||
|
|
||||||
|
print("[RECV] Waiting for TCF...")
|
||||||
|
drain()
|
||||||
|
os.write(fd, b"AT+FRM=96\r") # Receive at 14400 bps
|
||||||
|
tcf_result = wait_for("CONNECT", 5)
|
||||||
|
print(f"[RECV] TCF: {tcf_result.strip()}")
|
||||||
|
|
||||||
|
# Send CFR
|
||||||
|
print("[RECV] Sending CFR...")
|
||||||
|
drain()
|
||||||
|
os.write(fd, b"AT+FTH=3\r")
|
||||||
|
wait_for("CONNECT", 5)
|
||||||
|
cfr_data = bytes([0xFF, 0x13, 0x21]) # CFR frame
|
||||||
|
os.write(fd, cfr_data)
|
||||||
|
os.write(fd, b"\x00\x00\x00")
|
||||||
|
time.sleep(0.5)
|
||||||
|
wait_for("OK", 5)
|
||||||
|
print("[RECV] CFR sent")
|
||||||
|
|
||||||
|
print("[RECV] Waiting for page data...")
|
||||||
|
# Receive page
|
||||||
|
drain()
|
||||||
|
os.write(fd, b"AT+FRM=96\r")
|
||||||
|
page_result = wait_for("OK", 60)
|
||||||
|
print(f"[RECV] Page received!")
|
||||||
|
|
||||||
|
# Send MCF
|
||||||
|
print("[RECV] Sending MCF...")
|
||||||
|
drain()
|
||||||
|
os.write(fd, b"AT+FTH=3\r")
|
||||||
|
wait_for("CONNECT", 5)
|
||||||
|
mcf_data = bytes([0xFF, 0x13, 0x31]) # MCF frame
|
||||||
|
os.write(fd, mcf_data)
|
||||||
|
os.write(fd, b"\x00\x00\x00")
|
||||||
|
time.sleep(0.5)
|
||||||
|
wait_for("OK", 5)
|
||||||
|
print("[RECV] MCF sent")
|
||||||
|
|
||||||
|
# Wait for DCN or hangup
|
||||||
|
print("[RECV] Waiting for DCN...")
|
||||||
|
drain()
|
||||||
|
os.write(fd, b"AT+FRH=3\r")
|
||||||
|
dcn_result = wait_for("OK", 10)
|
||||||
|
print(f"[RECV] Session ended: {dcn_result[:50]}")
|
||||||
|
|
||||||
|
os.close(fd)
|
||||||
|
print("[RECV] Done!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user