Skip to main content

badterm_core/
modem.rs

1//! Dialing out with a real modem on a serial port (or anything that acts
2//! like one: a USB modem, a WiFi modem, tcpser).
3//!
4//! The connection thread opens the port, sends the init string and waits
5//! for OK, dials the entry's number with the dial command, and waits for
6//! the modem's verdict: CONNECT, or BUSY, NO CARRIER, NO DIALTONE, NO
7//! ANSWER. What the modem says shows on the terminal as it goes, as it
8//! would have in a terminal program of the day. Once connected, the line is
9//! passed through untouched until the carrier drops (DCD goes low, or,
10//! where the port does not report DCD, the modem says NO CARRIER).
11//! Hanging up drops DTR, then tries `+++` and `ATH0`.
12
13use std::io::{self, Read, Write};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::thread;
17use std::time::{Duration, Instant};
18
19use serde::{Deserialize, Serialize};
20use serialport::SerialPort;
21
22use crate::transport::{NetEvent, Writer, WriterKind};
23
24/// How long the modem has to answer the init string.
25const INIT_TIMEOUT: Duration = Duration::from_secs(5);
26/// How long a call may take to connect (S7 is often 50 seconds).
27const DIAL_TIMEOUT: Duration = Duration::from_secs(70);
28/// The pause around `+++` that tells the modem it is a command.
29const GUARD_TIME: Duration = Duration::from_millis(1100);
30/// How long a read waits, so the thread notices a hang-up.
31const POLL: Duration = Duration::from_millis(100);
32
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum FlowControl {
36    /// RTS/CTS, as most modems want.
37    #[default]
38    Hardware,
39    /// XON/XOFF.
40    Software,
41    None,
42}
43
44impl FlowControl {
45    pub const ALL: [FlowControl; 3] = [FlowControl::Hardware, FlowControl::Software, FlowControl::None];
46
47    pub fn name(self) -> &'static str {
48        match self {
49            FlowControl::Hardware => "RTS/CTS",
50            FlowControl::Software => "XON/XOFF",
51            FlowControl::None => "None",
52        }
53    }
54
55    fn serial(self) -> serialport::FlowControl {
56        match self {
57            FlowControl::Hardware => serialport::FlowControl::Hardware,
58            FlowControl::Software => serialport::FlowControl::Software,
59            FlowControl::None => serialport::FlowControl::None,
60        }
61    }
62}
63
64/// The modem, as set up in the settings.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(default)]
67pub struct ModemConfig {
68    /// The serial port: `/dev/ttyUSB0`, `/dev/cu.usbmodem1101`, `COM3`...
69    pub device: String,
70    /// The speed between the computer and the modem.
71    pub speed: u32,
72    pub flow: FlowControl,
73    /// Sent first, before dialing.
74    pub init: String,
75    /// Put before the number: `ATDT` for tone, `ATDP` for pulse.
76    pub dial: String,
77}
78
79impl Default for ModemConfig {
80    fn default() -> Self {
81        ModemConfig {
82            device: String::new(),
83            speed: 115_200,
84            flow: FlowControl::Hardware,
85            init: "ATZ".into(),
86            dial: "ATDT".into(),
87        }
88    }
89}
90
91/// The speeds offered for the serial port.
92pub const SPEEDS: [u32; 8] = [1200, 2400, 9600, 19_200, 38_400, 57_600, 115_200, 230_400];
93
94/// The serial ports the system lists (where it can).
95pub fn ports() -> Vec<String> {
96    serialport::available_ports().map(|ps| ps.into_iter().map(|p| p.port_name).collect()).unwrap_or_default()
97}
98
99/// What the modem answered a dial with.
100#[derive(Debug, PartialEq, Eq)]
101enum Verdict {
102    Connect,
103    Failed(&'static str),
104}
105
106/// The result codes a dial can end with, and what they mean.
107const RESULTS: [(&str, Option<&str>); 8] = [
108    ("CONNECT", None),
109    ("BUSY", Some("the line is busy (BUSY)")),
110    ("NO CARRIER", Some("no answer from a modem (NO CARRIER)")),
111    ("NO DIALTONE", Some("no dial tone (NO DIALTONE)")),
112    ("NO DIAL TONE", Some("no dial tone (NO DIAL TONE)")),
113    ("NO ANSWER", Some("no answer (NO ANSWER)")),
114    ("DELAYED", Some("the modem refuses to dial again so soon (DELAYED)")),
115    ("ERROR", Some("the modem did not take the dial command (ERROR)")),
116];
117
118/// The result code in what the modem has said so far, once a whole line of
119/// it has arrived.
120fn verdict(said: &str) -> Option<Verdict> {
121    said.split(['\r', '\n']).rev().skip(1).find_map(|line| {
122        let line = line.trim();
123        RESULTS.iter().find(|(code, _)| line.starts_with(code)).map(|(_, why)| match why {
124            None => Verdict::Connect,
125            Some(why) => Verdict::Failed(why),
126        })
127    })
128}
129
130/// Dial `number` on the modem in a background thread, reporting through
131/// `sink` like the network transports.
132pub fn spawn_modem<F>(number: String, config: ModemConfig, sink: F)
133where
134    F: Fn(NetEvent) + Send + 'static,
135{
136    thread::spawn(move || {
137        let failure = run(&number, &config, &sink).err();
138        sink(NetEvent::Closed(failure.map(|e| e.to_string())));
139    });
140}
141
142fn run<F: Fn(NetEvent)>(number: &str, config: &ModemConfig, sink: &F) -> Result<(), String> {
143    let device = config.device.trim();
144    if device.is_empty() {
145        return Err("choose the modem's serial port first: Settings (Alt-O), the Modem tab".into());
146    }
147    if number.trim().is_empty() {
148        return Err("the entry has no phone number: put it in \"Address\"".into());
149    }
150    let mut port = serialport::new(device, config.speed)
151        .flow_control(config.flow.serial())
152        .timeout(POLL)
153        .open()
154        .map_err(|e| format!("opening the modem on {device}: {e}"))?;
155    let _ = port.write_data_terminal_ready(true);
156    let _ = port.write_request_to_send(true);
157
158    let mut said = String::new();
159    if !config.init.trim().is_empty() {
160        send(&mut port, &format!("{}\r", config.init.trim()))?;
161        let answer =
162            wait(&mut port, sink, INIT_TIMEOUT, &mut said, |s| s.lines().any(|l| matches!(l.trim(), "OK" | "ERROR")))?;
163        if !answer {
164            return Err(format!("the modem on {device} did not answer \"{}\"", config.init.trim()));
165        }
166        if said.lines().any(|l| l.trim() == "ERROR") {
167            return Err(format!("the modem did not take the init string \"{}\" (ERROR)", config.init.trim()));
168        }
169    }
170    sink(NetEvent::Notice(format!("Dialing {number}...")));
171    said.clear();
172    send(&mut port, &format!("{}{}\r", config.dial.trim(), number.trim()))?;
173    let answered = wait(&mut port, sink, DIAL_TIMEOUT, &mut said, |s| verdict(s).is_some())?;
174    match verdict(&said) {
175        Some(Verdict::Connect) => {}
176        Some(Verdict::Failed(why)) => return Err(format!("connecting to {number}: {why}")),
177        None if !answered => {
178            // Stop the dial before giving up on it.
179            let _ = port.write_all(b"\r");
180            return Err(format!("connecting to {number}: no answer in {} seconds", DIAL_TIMEOUT.as_secs()));
181        }
182        None => unreachable!("waited for a verdict"),
183    }
184
185    let stop = Arc::new(AtomicBool::new(false));
186    let writer = port.try_clone().map_err(|e| format!("using the modem: {e}"))?;
187    sink(NetEvent::Connected(Writer(WriterKind::Serial(std::sync::Mutex::new(writer), stop.clone()))));
188    pass_through(&mut port, sink, &stop)
189}
190
191fn send(port: &mut Box<dyn SerialPort>, text: &str) -> Result<(), String> {
192    port.write_all(text.as_bytes()).map_err(|e| format!("talking to the modem: {e}"))
193}
194
195/// Read what the modem says, showing it, until `done` says so (true) or
196/// the time is up (false).
197fn wait<F: Fn(NetEvent)>(
198    port: &mut Box<dyn SerialPort>,
199    sink: &F,
200    limit: Duration,
201    said: &mut String,
202    done: impl Fn(&str) -> bool,
203) -> Result<bool, String> {
204    let deadline = Instant::now() + limit;
205    let mut buf = [0u8; 1024];
206    while Instant::now() < deadline {
207        match port.read(&mut buf) {
208            Ok(0) => {}
209            Ok(n) => {
210                sink(NetEvent::Data(buf[..n].to_vec()));
211                said.push_str(&String::from_utf8_lossy(&buf[..n]));
212                if done(said) {
213                    return Ok(true);
214                }
215            }
216            Err(e) if e.kind() == io::ErrorKind::TimedOut || e.kind() == io::ErrorKind::Interrupted => {}
217            Err(e) => return Err(format!("reading from the modem: {e}")),
218        }
219    }
220    Ok(false)
221}
222
223/// The call itself: everything the modem passes on, until the carrier
224/// drops or the user hangs up.
225fn pass_through<F: Fn(NetEvent)>(port: &mut Box<dyn SerialPort>, sink: &F, stop: &AtomicBool) -> Result<(), String> {
226    // Ports that report DCD show the carrier's end; others only say so in
227    // words.
228    let mut carrier_seen = port.read_carrier_detect().unwrap_or(false);
229    let mut tail: Vec<u8> = Vec::new();
230    let mut buf = vec![0u8; 16 * 1024];
231    loop {
232        if stop.load(Ordering::Relaxed) {
233            hang_up(port);
234            return Ok(());
235        }
236        match port.read(&mut buf) {
237            Ok(0) => {}
238            Ok(n) => {
239                sink(NetEvent::Data(buf[..n].to_vec()));
240                if !carrier_seen {
241                    tail.extend_from_slice(&buf[..n]);
242                    if crate::transfer::find(&tail, b"NO CARRIER").is_some() {
243                        return Ok(());
244                    }
245                    tail.drain(..tail.len().saturating_sub(16));
246                }
247            }
248            Err(e) if e.kind() == io::ErrorKind::TimedOut || e.kind() == io::ErrorKind::Interrupted => {}
249            Err(e) => return Err(format!("the modem: {e}")),
250        }
251        match port.read_carrier_detect() {
252            Ok(true) => carrier_seen = true,
253            Ok(false) if carrier_seen => return Ok(()),
254            _ => {}
255        }
256    }
257}
258
259/// Drop DTR (most modems hang up at once), then, if the carrier is still
260/// up or cannot be told, escape to command mode and hang up.
261fn hang_up(port: &mut Box<dyn SerialPort>) {
262    let _ = port.write_data_terminal_ready(false);
263    thread::sleep(Duration::from_millis(500));
264    if matches!(port.read_carrier_detect(), Ok(false)) {
265        return;
266    }
267    let _ = port.write_data_terminal_ready(true);
268    thread::sleep(GUARD_TIME);
269    let _ = port.write_all(b"+++");
270    thread::sleep(GUARD_TIME);
271    let _ = port.write_all(b"ATH0\r");
272    thread::sleep(Duration::from_millis(200));
273}
274
275/// The writing side of a modem call (see [`WriterKind::Serial`]).
276pub(crate) fn write(port: &std::sync::Mutex<Box<dyn SerialPort>>, data: &[u8]) -> io::Result<()> {
277    port.lock().unwrap_or_else(|e| e.into_inner()).write_all(data)
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn result_codes() {
286        assert_eq!(verdict("ATDT5551234\r\r\nCONNECT 33600\r\n"), Some(Verdict::Connect));
287        assert_eq!(verdict("ATDT5551234\r\r\nBUSY\r\n"), Some(Verdict::Failed("the line is busy (BUSY)")));
288        // Not until the line is whole.
289        assert_eq!(verdict("ATDT5551234\r\r\nCONNE"), None);
290        assert_eq!(verdict("ATDT5551234\r\r\n"), None);
291        assert!(matches!(verdict("\r\nNO DIALTONE\r\n"), Some(Verdict::Failed(_))));
292    }
293}