Skip to main content

serial_codec/
at.rs

1//! AT response line parsing and solicited vs URC classification.
2
3use serde::{Deserialize, Serialize};
4
5/// AT result line format (V.250 verbose vs numeric `ATV0`).
6#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "camelCase")]
8pub enum AtResultFormat {
9    #[default]
10    Verbose,
11    Numeric,
12}
13
14/// Parsed outcome of an AT exchange response.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "camelCase")]
17pub struct ParsedAtResponse {
18    pub status: AtParseStatus,
19    pub lines: Vec<String>,
20    pub solicited_body: Vec<String>,
21    pub urc_lines: Vec<String>,
22    pub final_line: Option<String>,
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "lowercase")]
27pub enum AtParseStatus {
28    Ok,
29    Error,
30    Cme,
31    Cms,
32    Unknown,
33}
34
35/// How the read loop decided the exchange finished.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38pub enum ExchangeMatch {
39    Ok,
40    Error,
41    /// `+CME ERROR:` (27.007 §9.2); a verbose text (`AT+CMEE=2`) is mapped to its code
42    /// (`None` for text the parser does not know).
43    CmeError {
44        code: Option<i32>,
45    },
46    /// `+CMS ERROR:` (27.005 §3.2.5), mapped like [`ExchangeMatch::CmeError`].
47    CmsError {
48        code: Option<i32>,
49    },
50    NoCarrier,
51    Busy,
52    NoAnswer,
53    NoDialtone,
54    /// `SEND OK`: not V.250, the final of module TCP stacks (`AT+CIPSEND`).
55    SendOk,
56    /// `SEND FAIL`: not V.250, see [`ExchangeMatch::SendOk`].
57    SendFail,
58    Intermediate {
59        line: String,
60    },
61    Substring {
62        term: String,
63    },
64    Idle,
65}
66
67impl ExchangeMatch {
68    pub fn to_parse_status(&self) -> AtParseStatus {
69        match self {
70            ExchangeMatch::Ok | ExchangeMatch::SendOk => AtParseStatus::Ok,
71            ExchangeMatch::Error
72            | ExchangeMatch::NoCarrier
73            | ExchangeMatch::Busy
74            | ExchangeMatch::NoAnswer
75            | ExchangeMatch::NoDialtone
76            | ExchangeMatch::SendFail => AtParseStatus::Error,
77            ExchangeMatch::CmeError { .. } => AtParseStatus::Cme,
78            ExchangeMatch::CmsError { .. } => AtParseStatus::Cms,
79            ExchangeMatch::Intermediate { .. }
80            | ExchangeMatch::Substring { .. }
81            | ExchangeMatch::Idle => AtParseStatus::Unknown,
82        }
83    }
84}
85
86/// Structured exchange result returned to the frontend.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct ExchangeResponse {
90    pub raw: Vec<u8>,
91    pub matched: ExchangeMatch,
92    pub lines: Vec<String>,
93    pub status: AtParseStatus,
94    pub solicited_body: Vec<String>,
95    pub urc_lines: Vec<String>,
96}
97
98/// AT command result returned by native `at` / `at_phases` / `send_sms_pdu`.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct AtCommandResult {
102    pub command: String,
103    pub response: String,
104    pub raw: Vec<u8>,
105    pub matched: ExchangeMatch,
106    pub lines: Vec<String>,
107    pub status: AtParseStatus,
108    pub solicited_body: Vec<String>,
109    pub urc_lines: Vec<String>,
110    pub timed_out: bool,
111}
112
113impl AtCommandResult {
114    pub fn from_exchange(command: String, exchange: ExchangeResponse) -> Self {
115        let response = String::from_utf8_lossy(&exchange.raw).into_owned();
116        Self {
117            command,
118            response,
119            raw: exchange.raw,
120            matched: exchange.matched,
121            lines: exchange.lines,
122            status: exchange.status,
123            solicited_body: exchange.solicited_body,
124            urc_lines: exchange.urc_lines,
125            timed_out: false,
126        }
127    }
128
129    pub fn failed(command: String, reason: String) -> Self {
130        Self {
131            command,
132            response: reason.clone(),
133            raw: reason.into_bytes(),
134            matched: ExchangeMatch::Error,
135            lines: Vec::new(),
136            status: AtParseStatus::Error,
137            solicited_body: Vec::new(),
138            urc_lines: Vec::new(),
139            timed_out: false,
140        }
141    }
142}
143
144impl ExchangeResponse {
145    pub fn from_raw(
146        raw: Vec<u8>,
147        matched: ExchangeMatch,
148        command: &str,
149        solicited_prefixes: &[String],
150        result_format: AtResultFormat,
151    ) -> Self {
152        let merged = merge_solicited_prefixes(command, solicited_prefixes);
153        let parsed = parse_at_response(&raw, command, &merged, result_format);
154        Self {
155            status: parsed.status,
156            lines: parsed.lines,
157            solicited_body: parsed.solicited_body,
158            urc_lines: parsed.urc_lines,
159            matched,
160            raw,
161        }
162    }
163}
164
165/// Merge user-provided prefixes with auto-derived ones from the command string.
166pub fn merge_solicited_prefixes(command: &str, user: &[String]) -> Vec<String> {
167    let mut out: Vec<String> = derive_solicited_prefixes(command);
168    for p in user {
169        let t = p.trim().to_string();
170        if !t.is_empty() && !out.iter().any(|x| x == &t) {
171            out.push(t);
172        }
173    }
174    out
175}
176
177/// Derive expected solicited response headers from an AT command (e.g. `AT+CSQ` →
178/// `+CSQ:`); a composite command gets one per part (`AT+CSQ;+CREG?` → `+CSQ:`, `+CREG:`).
179/// V.250 basic commands in front of an extended one are skipped (`ATE0V1+CSQ`,
180/// `AT&F+CSQ` → `+CSQ:`).
181pub fn derive_solicited_prefixes(command: &str) -> Vec<String> {
182    let cmd = normalize_command(command);
183    if !cmd.get(..2).is_some_and(|at| at.eq_ignore_ascii_case("AT")) {
184        return Vec::new();
185    }
186    let mut out: Vec<String> = Vec::new();
187    for header in cmd[2..].split(';').filter_map(part_header) {
188        if !out.contains(&header) {
189            out.push(header);
190        }
191    }
192    out
193}
194
195/// Header of one `;`-separated part: its extended command after any leading V.250
196/// basic commands (§5.2.1: `E1`, `V0`, `&F`, `&C1`, `S0=1`, `S3?`), else the part's
197/// own name (`I` → `I:`). `D` takes the rest of the part as its dial string.
198fn part_header(part: &str) -> Option<String> {
199    let part = part.trim();
200    let b = part.as_bytes();
201    let digits = |mut i: usize| {
202        while b.get(i).is_some_and(u8::is_ascii_digit) {
203            i += 1;
204        }
205        i
206    };
207    let mut i = 0;
208    while let Some(&c) = b.get(i) {
209        i = match c.to_ascii_uppercase() {
210            b' ' => i + 1,
211            b'&' if b.get(i + 1).is_some_and(u8::is_ascii_alphabetic) => digits(i + 2),
212            b'D' => break,
213            b'S' if b.get(i + 1).is_some_and(u8::is_ascii_digit) => {
214                let j = digits(i + 1);
215                match b.get(j) {
216                    Some(b'=') => digits(j + 1),
217                    Some(b'?') => j + 1,
218                    _ => j,
219                }
220            }
221            c if c.is_ascii_alphabetic() => digits(i + 1),
222            _ => break,
223        };
224    }
225    match b.get(i) {
226        Some(c) if i > 0 && b"+^#$%*".contains(c) => command_header(&part[i..]),
227        _ => command_header(part),
228    }
229}
230
231/// `+CSQ` → `+CSQ:`, `^SYSCFG?` → `^SYSCFG:`, `I` → `I:`.
232fn command_header(part: &str) -> Option<String> {
233    let part = part.trim();
234    let prefix_char = part.chars().next().filter(|c| "+^#$%*&".contains(*c));
235    let name: String = part
236        .trim_start_matches(['+', '^', '#', '$', '%', '*', '&'])
237        .chars()
238        .take_while(|c| c.is_ascii_alphanumeric())
239        .collect::<String>()
240        .to_ascii_uppercase();
241    if name.is_empty() {
242        return None;
243    }
244    Some(match prefix_char {
245        Some(ch) => format!("{ch}{name}:"),
246        None => format!("{name}:"),
247    })
248}
249
250/// Longest quoted `+CUSD` text kept open across embedded CR/LF (UCS2 hex of 182 chars + slack).
251const MAX_CUSD_SPAN: usize = 2_048;
252
253/// Split buffer into non-empty trimmed lines (handles `\r\n`, `\n` and a lone `\r`:
254/// V.250 §5.7.1 ends a numeric `ATV0` result code, and the echo, with `S3` only).
255///
256/// Two modem quirks are undone here, so every caller sees the same lines:
257/// - a final result glued to the next URC without CRLF (`OK+CUSD: …`) becomes two lines;
258/// - the quoted text of a `+CUSD:` may carry raw `\r`/`\n` (USSD menus): the line stays
259///   open until the quote closes. A result code, another `+XXX:` line or 2 KiB end an
260///   unclosed quote early (truncated `+CUSD`). Inside a `+CUSD` line CR/LF are kept.
261pub fn split_lines(text: &str) -> Vec<String> {
262    let segments: Vec<&str> = text.split('\n').collect();
263    group_segments(&segments, false).0
264}
265
266/// Group LF-separated segments into logical lines. With `wait_for_quote`, an open
267/// `+CUSD` quote at the end stops the scan; the second value is how many segments
268/// were consumed (the rest must be fed again with more bytes).
269fn group_segments(segments: &[&str], wait_for_quote: bool) -> (Vec<String>, usize) {
270    let mut out = Vec::new();
271    let mut i = 0;
272    while i < segments.len() {
273        let emitted = out.len();
274        let mut rest = segments[i].trim_start();
275        if let Some(head) = glued_final_head(rest.trim_end()) {
276            out.push(head.to_string());
277            rest = &rest[head.len()..];
278        }
279        let line = rest.trim_end();
280        if line.starts_with("+CUSD:") {
281            if has_open_quote(line) {
282                match close_cusd_quote(segments, i, line.len()) {
283                    QuoteEnd::Closed(j) => {
284                        let mut joined = rest.to_string();
285                        for seg in &segments[i + 1..=j] {
286                            joined.push('\n');
287                            joined.push_str(seg);
288                        }
289                        out.push(joined.trim_end().to_string());
290                        i = j + 1;
291                        continue;
292                    }
293                    QuoteEnd::Open if wait_for_quote => {
294                        out.truncate(emitted);
295                        return (out, i);
296                    }
297                    QuoteEnd::Open | QuoteEnd::Cut => {}
298                }
299            }
300            out.push(line.to_string());
301        } else {
302            for line in line.split('\r').map(str::trim).filter(|l| !l.is_empty()) {
303                out.push(line.to_string());
304            }
305        }
306        i += 1;
307    }
308    (out, segments.len())
309}
310
311enum QuoteEnd {
312    /// Quote closes in segment `j`.
313    Closed(usize),
314    /// Ended early by a modem line / size cap: keep the first segment as a truncated line.
315    Cut,
316    /// No closing quote yet.
317    Open,
318}
319
320fn close_cusd_quote(segments: &[&str], first: usize, first_len: usize) -> QuoteEnd {
321    let mut span = first_len;
322    for (j, seg) in segments.iter().enumerate().skip(first + 1) {
323        span += seg.len() + 1;
324        let seg = seg.trim();
325        if span > MAX_CUSD_SPAN || classify_final_line(seg).is_some() || is_urc_shape(seg) {
326            return QuoteEnd::Cut;
327        }
328        if has_open_quote(seg) {
329            return QuoteEnd::Closed(j);
330        }
331    }
332    QuoteEnd::Open
333}
334
335fn has_open_quote(s: &str) -> bool {
336    s.matches('"').count() % 2 == 1
337}
338
339/// `OK+CUSD: …` → `Some("OK")`: a verbose final result glued to a URC line.
340fn glued_final_head(line: &str) -> Option<&str> {
341    let idx = line.find('+').filter(|&i| i > 0)?;
342    let (head, rest) = line.split_at(idx);
343    (classify_final_line(head).is_some() && is_urc_shape(rest)).then_some(head)
344}
345
346/// Strict shape of an unsolicited line: `RING`, or `+NAME:` / `^NAME:` / `#NAME:` … with
347/// an upper-case name. SMS text such as `*100#`, `+7 900 …` or `#tag` does not match.
348fn is_urc_shape(line: &str) -> bool {
349    let t = line.trim();
350    if t == "RING" {
351        return true;
352    }
353    let Some(body) = t.strip_prefix(['+', '^', '#', '$', '%', '*']) else {
354        return false;
355    };
356    body.split_once(':').is_some_and(|(name, _)| {
357        name.starts_with(|c: char| c.is_ascii_uppercase())
358            && name
359                .chars()
360                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
361    })
362}
363
364/// Header whose next line is data (27.005): `Some(true)` when more text lines may
365/// follow (text-mode `+CMGR`/`+CMGL` bodies), `Some(false)` for exactly one data line
366/// (`+CMT`, `+CBM`, PDU-mode `+CDS: <length>`).
367fn two_line_header(line: &str) -> Option<bool> {
368    if line.starts_with("+CMGR:") || line.starts_with("+CMGL:") {
369        return Some(true);
370    }
371    if line.starts_with("+CMT:") || line.starts_with("+CBM:") {
372        return Some(false);
373    }
374    let len = line.strip_prefix("+CDS:")?.trim();
375    (!len.is_empty() && len.chars().all(|c| c.is_ascii_digit())).then_some(false)
376}
377
378/// Vendor extended information line (`+CSQ:`, `^SYSCFG:`, `#SGACT:`, …).
379pub fn is_ext_info_line(line: &str) -> bool {
380    let t = line.trim();
381    t.starts_with('+')
382        || t.starts_with('^')
383        || t.starts_with('#')
384        || t.starts_with('$')
385        || t.starts_with('%')
386        || t.starts_with('*')
387}
388
389/// True when the line is a strict V.250 numeric code (`0`…`99`).
390pub fn is_numeric_result_line(line: &str) -> bool {
391    let t = line.trim();
392    !t.is_empty() && t.len() <= 2 && t.chars().all(|c| c.is_ascii_digit())
393}
394
395/// Intermediate result codes that must not complete an exchange (V.250 verbose).
396pub fn classify_intermediate_line(line: &str) -> bool {
397    let t = line.trim();
398    t == "CONNECT" || t == ">" || t.starts_with("CONNECT ")
399}
400
401/// Intermediate lines including numeric `1` (CONNECT) when `ATV0` is active.
402pub fn classify_intermediate_line_with_format(line: &str, format: AtResultFormat) -> bool {
403    classify_intermediate_line(line) || (format == AtResultFormat::Numeric && line.trim() == "1")
404}
405
406/// V.250 numeric final result codes (`ATV0`): `0` OK, `3` NO CARRIER, `4` ERROR,
407/// `6` NO DIALTONE, `7` BUSY, `8` NO ANSWER. `1` (CONNECT) is intermediate, `2` (RING)
408/// unsolicited, `5` is unassigned.
409///
410/// In numeric mode a one-digit SMS body (`0`, `4`, …) in a `+CMGR`/`+CMGL` reply is
411/// indistinguishable from a result code and ends the exchange early; read SMS in
412/// verbose mode (`ATV1`).
413pub fn classify_numeric_final_line(line: &str) -> Option<ExchangeMatch> {
414    if !is_numeric_result_line(line) {
415        return None;
416    }
417    match line.trim() {
418        "0" => Some(ExchangeMatch::Ok),
419        "3" => Some(ExchangeMatch::NoCarrier),
420        "4" => Some(ExchangeMatch::Error),
421        "6" => Some(ExchangeMatch::NoDialtone),
422        "7" => Some(ExchangeMatch::Busy),
423        "8" => Some(ExchangeMatch::NoAnswer),
424        _ => None,
425    }
426}
427
428fn classify_final_line_with_format(
429    line: &str,
430    format: AtResultFormat,
431    ignore_case: bool,
432) -> Option<ExchangeMatch> {
433    classify_final_line_cased(line, ignore_case).or_else(|| {
434        if format == AtResultFormat::Numeric {
435            classify_numeric_final_line(line)
436        } else {
437            None
438        }
439    })
440}
441
442/// End (inclusive) of the complete lines in `buf`: the last LF, or in numeric mode
443/// also the last CR (V.250 §5.7.1: `ATV0` result codes end with `S3` alone). Bytes
444/// after it may already belong to what comes next (e.g. CMUX flags right after
445/// `AT+CMUX=0` → `OK`, in the same read).
446fn complete_end(buf: &[u8], format: AtResultFormat) -> Option<usize> {
447    buf.iter()
448        .rposition(|&b| b == b'\n' || (b == b'\r' && format == AtResultFormat::Numeric))
449}
450
451/// Returns `Some(match)` when the buffer holds a complete AT final line.
452///
453/// The last final result after the echo wins; whatever follows it in the same read
454/// (`RING`, a two-line `+CMT` with its PDU, `RDY` / `Call Ready`, the rest of a USSD
455/// menu) is trailing unsolicited output and does not hide it. Bytes are decoded
456/// lossily so line noise or a Latin-1 phonebook entry cannot stall the exchange.
457pub fn at_final_line_complete(buf: &[u8], result_format: AtResultFormat) -> Option<ExchangeMatch> {
458    scan_completion(buf, result_format, false)
459}
460
461/// Shared by both completion checks: one pass of an [`AtCompletion`] over `buf`.
462fn scan_completion(
463    buf: &[u8],
464    format: AtResultFormat,
465    intermediate: bool,
466) -> Option<ExchangeMatch> {
467    AtCompletion::new(intermediate, format).push(buf)
468}
469
470/// Incremental form of [`at_final_line_complete`] / [`at_intermediate_line_complete`]:
471/// feed the reply chunk by chunk; each line is judged once, so a long reply costs
472/// time linear in its size. After each [`push`](Self::push) the answer is the one the
473/// one-shot check gives for all bytes pushed so far.
474#[derive(Debug)]
475pub struct AtCompletion {
476    lines: LineAssembler,
477    classifier: LineClassifier,
478    format: AtResultFormat,
479    intermediate: bool,
480    last: Option<ExchangeMatch>,
481}
482
483impl AtCompletion {
484    /// `intermediate`: also complete on `CONNECT` / `1` / the `> ` prompt.
485    pub fn new(intermediate: bool, format: AtResultFormat) -> Self {
486        Self {
487            lines: LineAssembler::new(format),
488            classifier: LineClassifier::new("", &[], format),
489            format,
490            intermediate,
491            last: None,
492        }
493    }
494
495    /// Feed the next bytes; returns the completion state for everything fed so far.
496    pub fn push(&mut self, chunk: &[u8]) -> Option<ExchangeMatch> {
497        for line in self.lines.push(chunk) {
498            self.judge(&line);
499        }
500        // 27.005 sends the SMS prompt as `\r\n> ` with no line feed after it.
501        if self.intermediate && String::from_utf8_lossy(self.lines.tail()).trim() == ">" {
502            return Some(ExchangeMatch::Intermediate { line: ">".into() });
503        }
504        self.last.clone()
505    }
506
507    fn judge(&mut self, line: &str) {
508        let in_data = self.classifier.in_data();
509        match self.classifier.feed(line) {
510            LineKind::Final(m) => self.last = Some(m),
511            // The data line of a two-line URC (`+CMT` text) is never an echo.
512            _ if in_data => {}
513            _ if self.intermediate && classify_intermediate_line_with_format(line, self.format) => {
514                self.last = Some(ExchangeMatch::Intermediate {
515                    line: line.to_string(),
516                })
517            }
518            // Without the command, an echo is any `AT…` line: it starts a new reply,
519            // so an earlier final (late reply to a previous command) is stale.
520            _ if line.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("AT")) => self.last = None,
521            _ => {}
522        }
523    }
524}
525
526/// Completion for prompt-style commands (`AT+CMGS=…` → `> `, `ATD` → `CONNECT`).
527///
528/// 27.005 sends the SMS prompt as `\r\n> ` with no line feed after it, so the last
529/// unterminated line counts too. A final result (`+CMS ERROR: …`, `ERROR`) also
530/// completes, otherwise a rejected command would wait out the whole timeout. Data
531/// after `CONNECT` in the same read (PPP frames, CMUX flags) does not hide it.
532pub fn at_intermediate_line_complete(
533    buf: &[u8],
534    result_format: AtResultFormat,
535) -> Option<ExchangeMatch> {
536    scan_completion(buf, result_format, true)
537}
538
539/// Final result line, case-insensitive (`OK`, `ok`, …): the V.250 verbose codes,
540/// `+CME ERROR:` (27.007) and `+CMS ERROR:` (27.005). `SEND OK` / `SEND FAIL` are
541/// not V.250: they are the finals of the TCP/IP stacks of many modules
542/// (`AT+CIPSEND`) and stay for compatibility.
543pub fn classify_final_line(line: &str) -> Option<ExchangeMatch> {
544    classify_final_line_cased(line, true)
545}
546
547fn classify_final_line_cased(line: &str, ignore_case: bool) -> Option<ExchangeMatch> {
548    let trimmed = line.trim();
549    let upper;
550    let key = if ignore_case {
551        upper = trimmed.to_ascii_uppercase();
552        upper.as_str()
553    } else {
554        trimmed
555    };
556    // ASCII upper-casing keeps byte offsets, so `trimmed[11..]` is the original detail.
557    match key {
558        "OK" => Some(ExchangeMatch::Ok),
559        "ERROR" => Some(ExchangeMatch::Error),
560        "NO CARRIER" => Some(ExchangeMatch::NoCarrier),
561        "BUSY" => Some(ExchangeMatch::Busy),
562        "NO ANSWER" => Some(ExchangeMatch::NoAnswer),
563        "NO DIALTONE" => Some(ExchangeMatch::NoDialtone),
564        "SEND OK" => Some(ExchangeMatch::SendOk),
565        "SEND FAIL" => Some(ExchangeMatch::SendFail),
566        s if s.starts_with("+CME ERROR:") => {
567            let (code, _) = parse_error_detail(&trimmed[11..], CME_TEXT_CODES);
568            Some(ExchangeMatch::CmeError { code })
569        }
570        s if s.starts_with("+CMS ERROR:") => {
571            let (code, _) = parse_error_detail(&trimmed[11..], CMS_TEXT_CODES);
572            Some(ExchangeMatch::CmsError { code })
573        }
574        _ => None,
575    }
576}
577
578/// Verbose `+CME ERROR:` texts (`AT+CMEE=2`) → numeric codes (27.007 §9.2). Texts are
579/// compared loosely (see [`text_key`]); a code may have several spellings.
580const CME_TEXT_CODES: &[(&str, i32)] = &[
581    ("phone failure", 0),
582    ("no connection to phone", 1),
583    ("no connection", 1),
584    ("phone-adaptor link reserved", 2),
585    ("link reserved", 2),
586    ("operation not allowed", 3),
587    ("operation not supported", 4),
588    ("PH-SIM PIN required", 5),
589    ("PH-FSIM PIN required", 6),
590    ("PH-FSIM PUK required", 7),
591    ("SIM not inserted", 10),
592    ("SIM PIN required", 11),
593    ("SIM PUK required", 12),
594    ("SIM failure", 13),
595    ("SIM busy", 14),
596    ("SIM wrong", 15),
597    ("incorrect password", 16),
598    ("SIM PIN2 required", 17),
599    ("SIM PUK2 required", 18),
600    ("memory full", 20),
601    ("invalid index", 21),
602    ("not found", 22),
603    ("memory failure", 23),
604    ("text string too long", 24),
605    ("invalid characters in text string", 25),
606    ("dial string too long", 26),
607    ("invalid characters in dial string", 27),
608    ("no network service", 30),
609    ("no network", 30),
610    ("network timeout", 31),
611    ("timeout", 31),
612    ("network not allowed - emergency calls only", 32),
613    ("network personalization PIN required", 40),
614    ("network personalization PUK required", 41),
615    ("network subset personalization PIN required", 42),
616    ("network subset personalization PUK required", 43),
617    ("service provider personalization PIN required", 44),
618    ("service provider personalization PUK required", 45),
619    ("corporate personalization PIN required", 46),
620    ("corporate personalization PUK required", 47),
621    ("hidden key required", 48),
622    ("EAP method not supported", 49),
623    ("incorrect parameters", 50),
624    ("command implemented but currently disabled", 51),
625    ("command aborted by user", 52),
626    (
627        "not attached to network due to MT functionality restrictions",
628        53,
629    ),
630    (
631        "modem not allowed - MT restricted to emergency calls only",
632        54,
633    ),
634    (
635        "operation not allowed because of MT functionality restrictions",
636        55,
637    ),
638    (
639        "fixed dial number only allowed - called number is not a fixed dial number",
640        56,
641    ),
642    ("temporarily out of service due to other MT usage", 57),
643    ("language/alphabet not supported", 58),
644    ("language or alphabet not supported", 58),
645    ("unexpected data value", 59),
646    ("system failure", 60),
647    ("data missing", 61),
648    ("call barred", 62),
649    ("message waiting indication subscription failure", 63),
650    ("unknown", 100),
651    // 27.007 §9.2.2 packet domain
652    ("illegal MS", 103),
653    ("illegal ME", 106),
654    ("GPRS services not allowed", 107),
655    ("PLMN not allowed", 111),
656    ("location area not allowed", 112),
657    ("roaming not allowed in this location area", 113),
658    ("service option not supported", 132),
659    ("requested service option not subscribed", 133),
660    ("service option temporarily out of order", 134),
661    ("unspecified GPRS error", 148),
662    ("PDP authentication failure", 149),
663    ("invalid mobile class", 150),
664];
665
666/// Verbose `+CMS ERROR:` texts → numeric codes (27.005 §3.2.5).
667const CMS_TEXT_CODES: &[(&str, i32)] = &[
668    ("ME failure", 300),
669    ("SMS service of ME reserved", 301),
670    ("SMS service reserved", 301),
671    ("operation not allowed", 302),
672    ("operation not supported", 303),
673    ("invalid PDU mode parameter", 304),
674    ("invalid PDU parameter", 304),
675    ("invalid text mode parameter", 305),
676    ("invalid text parameter", 305),
677    ("SIM not inserted", 310),
678    ("SIM PIN required", 311),
679    ("PH-SIM PIN required", 312),
680    ("SIM failure", 313),
681    ("SIM busy", 314),
682    ("SIM wrong", 315),
683    ("SIM PUK required", 316),
684    ("SIM PIN2 required", 317),
685    ("SIM PUK2 required", 318),
686    ("memory failure", 320),
687    ("invalid memory index", 321),
688    ("invalid index", 321),
689    ("memory full", 322),
690    ("SMSC address unknown", 330),
691    ("no network service", 331),
692    ("no network", 331),
693    ("network timeout", 332),
694    ("no +CNMA acknowledgement expected", 340),
695    ("unknown error", 500),
696];
697
698/// Comparison key for error texts: letters and digits only, lower case, British
699/// `-isation` folded to `-ization`, so `SIM-PIN required`, `simpinrequired` and
700/// `Network personalisation PIN required.` find their table row.
701fn text_key(text: &str) -> String {
702    text.chars()
703        .filter(char::is_ascii_alphanumeric)
704        .collect::<String>()
705        .to_ascii_lowercase()
706        .replace("isation", "ization")
707}
708
709/// `14` → (14, None); `10: SIM not inserted` → (10, text); `SIM busy` → (table, text).
710fn parse_error_detail(detail: &str, table: &[(&str, i32)]) -> (Option<i32>, Option<String>) {
711    let detail = detail.trim();
712    if let Ok(code) = detail.parse() {
713        return (Some(code), None);
714    }
715    if let Some((code, text)) = detail.split_once(':') {
716        if let Ok(code) = code.trim().parse() {
717            let text = text.trim();
718            return (Some(code), (!text.is_empty()).then(|| text.to_string()));
719        }
720    }
721    let text = detail.trim_matches('"').trim();
722    let key = text_key(text);
723    let code = table
724        .iter()
725        .find(|(message, _)| text_key(message) == key)
726        .map(|(_, code)| *code);
727    (code, (!text.is_empty()).then(|| text.to_string()))
728}
729
730/// What a reply line is, judged in order (see [`LineClassifier::feed`]).
731#[derive(Debug, Clone, PartialEq, Eq)]
732enum LineKind {
733    Echo,
734    Body,
735    Urc,
736    Final(ExchangeMatch),
737}
738
739/// Line-by-line solicited / URC classification shared by the one-shot parser, the
740/// completion check and the live demux, so they never disagree about a line.
741#[derive(Debug)]
742struct LineClassifier {
743    command: String,
744    prefixes: Vec<String>,
745    format: AtResultFormat,
746    echo_seen: bool,
747    /// Kind of the next line: the data line of a two-line header (`+CMT`, `+CMGR`, …),
748    /// plus whether more text-body lines may follow it.
749    pending: Option<(LineKind, bool)>,
750    /// Inside a text-mode `+CMGR`/`+CMGL` body: only strict URC shapes are URCs.
751    in_text_body: bool,
752}
753
754impl LineClassifier {
755    fn new(command: &str, prefixes: &[String], format: AtResultFormat) -> Self {
756        Self {
757            command: normalize_command(command),
758            prefixes: prefixes.to_vec(),
759            format,
760            echo_seen: false,
761            pending: None,
762            in_text_body: false,
763        }
764    }
765
766    /// The next line is SMS data (a two-line header's payload or a text body).
767    fn in_data(&self) -> bool {
768        self.pending.is_some() || self.in_text_body
769    }
770
771    fn feed(&mut self, line: &str) -> LineKind {
772        // SMS text is often lower-case (`ok`): inside a body only exact codes end it.
773        let in_data = self.in_data();
774        if let Some(m) = classify_final_line_with_format(line, self.format, !in_data) {
775            self.pending = None;
776            self.in_text_body = false;
777            return LineKind::Final(m);
778        }
779        if let Some((kind, text_body)) = self.pending.take() {
780            self.in_text_body = text_body;
781            return kind;
782        }
783        if !self.echo_seen && !self.command.is_empty() && lines_equivalent(line, &self.command) {
784            self.echo_seen = true;
785            return LineKind::Echo;
786        }
787        // Inside the reply (after the echo, or in an SMS text body), and whenever the
788        // command is known (`ATE0` sends no echo), only a strict URC shape with a
789        // foreign header is a URC: `+IFC (0,1,2)`, a garbled `+CSCSERROR` or the
790        // `+CGMI` of an `AT+CLAC` list stay reply text, `+CMTI: "SM",3` does not.
791        let strict = self.in_text_body || self.echo_seen || !self.command.is_empty();
792        let kind = if strict {
793            if is_urc_shape(line) && is_likely_urc(line, &self.prefixes) {
794                LineKind::Urc
795            } else {
796                LineKind::Body
797            }
798        } else if is_likely_urc(line, &self.prefixes) {
799            LineKind::Urc
800        } else {
801            LineKind::Body
802        };
803        if let Some(text_body) = two_line_header(line) {
804            self.pending = Some((kind.clone(), text_body));
805        }
806        kind
807    }
808}
809
810/// Classify lines into solicited body vs URC using echo / solicited prefixes.
811///
812/// The data line after `+CMGR:`/`+CMGL:`/`+CMT:`/`+CBM:`/`+CDS: <len>` belongs to its
813/// header (body or URC, kept adjacent). Lines after the final result are URCs.
814pub fn parse_at_response(
815    raw: &[u8],
816    command: &str,
817    solicited_prefixes: &[String],
818    result_format: AtResultFormat,
819) -> ParsedAtResponse {
820    let merged = merge_solicited_prefixes(command, solicited_prefixes);
821    // Same view as completion: complete lines only, and the final result code may be
822    // followed by URC lines (or CMUX flags) that arrived in the same read.
823    let complete = match complete_end(raw, result_format) {
824        Some(end) => &raw[..=end],
825        None => raw,
826    };
827    let lines = split_lines(&String::from_utf8_lossy(complete));
828    let mut classifier = LineClassifier::new(command, &merged, result_format);
829    let kinds: Vec<LineKind> = lines.iter().map(|l| classifier.feed(l)).collect();
830    let echo_idx = kinds.iter().position(|k| *k == LineKind::Echo);
831    let final_idx = kinds
832        .iter()
833        .rposition(|k| matches!(k, LineKind::Final(_)))
834        .filter(|&i| echo_idx.is_none_or(|e| i > e));
835    // Without a final result (substring / idle completion, a prompt, a timeout) every
836    // line is body or URC.
837    let final_pos = final_idx.unwrap_or(lines.len());
838    let final_line = lines.get(final_pos).cloned();
839    let status = match final_idx.map(|i| &kinds[i]) {
840        Some(LineKind::Final(m)) => m.to_parse_status(),
841        _ => AtParseStatus::Unknown,
842    };
843
844    let mut urc_lines = Vec::new();
845    let mut solicited_body = Vec::new();
846    for (line, kind) in lines.iter().zip(&kinds).take(final_pos) {
847        match kind {
848            LineKind::Echo => {}
849            LineKind::Urc => urc_lines.push(line.clone()),
850            LineKind::Body => solicited_body.push(line.clone()),
851            // An earlier result-like line: `NO CARRIER` before the echo is a URC.
852            LineKind::Final(_) if is_likely_urc(line, &merged) => urc_lines.push(line.clone()),
853            LineKind::Final(_) => solicited_body.push(line.clone()),
854        }
855    }
856    if let Some(i) = final_idx {
857        urc_lines.extend(lines.iter().skip(i + 1).cloned());
858    }
859
860    ParsedAtResponse {
861        status,
862        lines,
863        solicited_body,
864        urc_lines,
865        final_line,
866    }
867}
868
869fn normalize_command(command: &str) -> String {
870    command
871        .trim()
872        .trim_end_matches('\r')
873        .trim_end_matches('\n')
874        .to_string()
875}
876
877fn lines_equivalent(line: &str, command: &str) -> bool {
878    line.trim().eq_ignore_ascii_case(command)
879}
880
881pub fn is_likely_urc(line: &str, solicited_prefixes: &[String]) -> bool {
882    let trimmed = line.trim();
883    if !is_ext_info_line(trimmed) {
884        return matches!(trimmed, "RING" | "NO CARRIER" | "BUSY" | "NO ANSWER");
885    }
886    !solicited_prefixes
887        .iter()
888        .any(|p| trimmed.starts_with(p.trim()))
889}
890
891/// Exchange demux phase, see [`ExchangeDemux::phase`].
892#[derive(Debug, Clone, Copy, PartialEq, Eq)]
893pub enum ExchangePhase {
894    /// The echo of the command has not been seen (yet, or ever with `ATE0`).
895    AwaitingFirstLine,
896    /// The echo was seen: the reply is being collected.
897    CollectingSolicited,
898}
899
900/// Longest unterminated line [`ExchangeDemux`] keeps; longer runs without a line
901/// feed (data after `CONNECT`, binary noise) cannot be a URC and are dropped.
902pub const MAX_DEMUX_LINE: usize = 65_536;
903
904/// Routes bytes during an exchange: URC lines are emitted live, the rest is the reply.
905///
906/// Each line is judged once, when it completes, so every URC occurrence (two `RING`s
907/// in one exchange) is emitted exactly once.
908#[derive(Debug)]
909pub struct ExchangeDemux {
910    phase: ExchangePhase,
911    command: String,
912    solicited_prefixes: Vec<String>,
913    format: AtResultFormat,
914    lines: LineAssembler,
915    classifier: LineClassifier,
916}
917
918/// Turns chunks into logical lines (the [`split_lines`] rules) as they complete. A
919/// line ends at LF, in numeric mode also at CR (V.250 §5.7.1); an open `+CUSD` quote
920/// waits for more bytes. Only new bytes are searched for a line end.
921#[derive(Debug)]
922struct LineAssembler {
923    numeric: bool,
924    /// Bytes of an unfinished line (kept raw so a split UTF-8 char survives).
925    partial: Vec<u8>,
926    /// Dropping an overlong line until its line end.
927    skipping: bool,
928}
929
930impl LineAssembler {
931    fn new(format: AtResultFormat) -> Self {
932        Self {
933            numeric: format == AtResultFormat::Numeric,
934            partial: Vec::new(),
935            skipping: false,
936        }
937    }
938
939    fn is_end(&self, b: u8) -> bool {
940        b == b'\n' || (self.numeric && b == b'\r')
941    }
942
943    /// Bytes after the last complete line.
944    fn tail(&self) -> &[u8] {
945        &self.partial
946    }
947
948    fn clear(&mut self) {
949        self.partial.clear();
950        self.skipping = false;
951    }
952
953    fn push(&mut self, mut chunk: &[u8]) -> Vec<String> {
954        if self.skipping {
955            let Some(e) = chunk.iter().position(|&b| self.is_end(b)) else {
956                return Vec::new();
957            };
958            self.skipping = false;
959            chunk = &chunk[e + 1..];
960        }
961        let Some(e) = chunk.iter().rposition(|&b| self.is_end(b)) else {
962            self.partial.extend_from_slice(chunk);
963            if self.partial.len() > MAX_DEMUX_LINE {
964                self.partial.clear();
965                self.skipping = true;
966            }
967            return Vec::new();
968        };
969        let end = self.partial.len() + e;
970        self.partial.extend_from_slice(chunk);
971        let text = String::from_utf8_lossy(&self.partial[..end]).into_owned();
972        let segments: Vec<&str> = text.split('\n').collect();
973        let (lines, used) = group_segments(&segments, true);
974        // LF is never part of a multi-byte sequence, so counting it in the raw bytes
975        // maps consumed segments back to a byte offset.
976        let cut = match used {
977            0 => 0,
978            n if n == segments.len() => end + 1,
979            n => self
980                .partial
981                .iter()
982                .enumerate()
983                .filter(|(_, &b)| b == b'\n')
984                .nth(n - 1)
985                .map_or(0, |(i, _)| i + 1),
986        };
987        self.partial.drain(..cut);
988        if self.partial.len() > MAX_DEMUX_LINE {
989            self.skipping = !self.partial.last().is_some_and(|&b| self.is_end(b));
990            self.partial.clear();
991        }
992        lines
993    }
994}
995
996impl ExchangeDemux {
997    /// Demux for a verbose (`ATV1`) modem.
998    pub fn new(command: &str, solicited_prefixes: &[String]) -> Self {
999        Self::with_format(command, solicited_prefixes, AtResultFormat::Verbose)
1000    }
1001
1002    /// Demux that judges result codes like the completion check with `format`.
1003    pub fn with_format(
1004        command: &str,
1005        solicited_prefixes: &[String],
1006        format: AtResultFormat,
1007    ) -> Self {
1008        let command = normalize_command(command);
1009        let solicited_prefixes = merge_solicited_prefixes(&command, solicited_prefixes);
1010        Self {
1011            phase: ExchangePhase::AwaitingFirstLine,
1012            classifier: LineClassifier::new(&command, &solicited_prefixes, format),
1013            command,
1014            solicited_prefixes,
1015            format,
1016            lines: LineAssembler::new(format),
1017        }
1018    }
1019
1020    /// Whether the echo of the command has been seen.
1021    pub fn phase(&self) -> ExchangePhase {
1022        self.phase
1023    }
1024
1025    pub fn reset(&mut self) {
1026        self.phase = ExchangePhase::AwaitingFirstLine;
1027        self.lines.clear();
1028        self.classifier = LineClassifier::new(&self.command, &self.solicited_prefixes, self.format);
1029    }
1030
1031    /// Process complete lines from a chunk; returns new URC lines to emit live.
1032    ///
1033    /// Cost is linear in the chunk: only the new bytes are searched for a line end,
1034    /// and an unterminated line is capped at [`MAX_DEMUX_LINE`].
1035    pub fn process_chunk(&mut self, chunk: &[u8]) -> Vec<String> {
1036        let lines = self.lines.push(chunk);
1037        let mut live_urc = Vec::new();
1038        for line in lines {
1039            match self.classifier.feed(&line) {
1040                LineKind::Echo => self.phase = ExchangePhase::CollectingSolicited,
1041                LineKind::Urc => live_urc.push(line),
1042                LineKind::Body | LineKind::Final(_) => {}
1043            }
1044        }
1045        live_urc
1046    }
1047}
1048
1049/// Extract URC lines from drained bytes (for drainRx forwarding). The data line of a
1050/// two-line URC (`+CMT: ,23` + PDU) comes right after its header.
1051pub fn urc_lines_from_drain(bytes: &[u8], solicited_prefixes: &[String]) -> Vec<String> {
1052    let mut classifier = LineClassifier::new("", solicited_prefixes, AtResultFormat::Verbose);
1053    split_lines(&String::from_utf8_lossy(bytes))
1054        .into_iter()
1055        .filter(|l| match classifier.feed(l) {
1056            LineKind::Urc => true,
1057            LineKind::Final(_) => is_likely_urc(l, solicited_prefixes),
1058            LineKind::Echo | LineKind::Body => false,
1059        })
1060        .collect()
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::AtResultFormat as F;
1066
1067    #[test]
1068    fn urc_after_echo_is_still_emitted() {
1069        let mut demux = ExchangeDemux::new("AT+CSQ", &[]);
1070        let urc = demux.process_chunk(b"AT+CSQ\r\r\n+CMTI: \"SM\",3\r\n+CSQ: 20,0\r\n\r\nOK\r\n");
1071        assert_eq!(urc, vec!["+CMTI: \"SM\",3".to_string()]);
1072    }
1073
1074    #[test]
1075    fn sms_prompt_without_trailing_lf_completes() {
1076        let m = at_intermediate_line_complete(b"AT+CMGS=20\r\r\n> ", F::Verbose);
1077        assert!(
1078            matches!(m, Some(ExchangeMatch::Intermediate { .. })),
1079            "{m:?}"
1080        );
1081    }
1082
1083    #[test]
1084    fn prompt_command_rejected_with_cms_error_completes() {
1085        let m = at_intermediate_line_complete(b"AT+CMGS=20\r\r\n+CMS ERROR: 304\r\n", F::Verbose);
1086        assert!(
1087            matches!(
1088                m,
1089                Some(ExchangeMatch::CmsError {
1090                    code: Some(304),
1091                    ..
1092                })
1093            ),
1094            "{m:?}"
1095        );
1096    }
1097
1098    /// Seen on a Quectel M26: `OK` and the first CMUX flags arrive in one read.
1099    #[test]
1100    fn trailing_bytes_after_final_line_do_not_hide_it() {
1101        let m = at_final_line_complete(b"AT+CMUX=0\r\r\nOK\r\n\xF9\xF9\xF9\xF9", F::Verbose);
1102        assert_eq!(m, Some(ExchangeMatch::Ok));
1103    }
1104
1105    #[test]
1106    fn parsed_status_ignores_trailing_bytes_and_urcs() {
1107        let p = parse_at_response(
1108            b"AT+CMUX=0\r\r\nOK\r\n\xF9\xF9",
1109            "AT+CMUX=0",
1110            &[],
1111            F::Verbose,
1112        );
1113        assert_eq!(p.status, AtParseStatus::Ok);
1114        let p = parse_at_response(b"AT\r\r\nOK\r\n\r\nRING\r\n", "AT", &[], F::Verbose);
1115        assert_eq!(p.status, AtParseStatus::Ok);
1116        assert_eq!(p.urc_lines, vec!["RING".to_string()]);
1117    }
1118
1119    #[test]
1120    fn non_utf8_noise_does_not_block_final_line() {
1121        let m = at_final_line_complete(b"\xFF\xFEAT\r\r\nOK\r\n", F::Verbose);
1122        assert_eq!(m, Some(ExchangeMatch::Ok));
1123    }
1124
1125    #[test]
1126    fn urc_in_same_read_after_ok_does_not_hide_it() {
1127        let m = at_final_line_complete(b"AT\r\r\nOK\r\n\r\nRING\r\n", F::Verbose);
1128        assert_eq!(m, Some(ExchangeMatch::Ok));
1129        assert_eq!(at_final_line_complete(b"+CSQ: 20,0\r\n", F::Verbose), None);
1130    }
1131
1132    use super::*;
1133
1134    #[test]
1135    fn final_line_ok_only_at_end() {
1136        let buf = b"AT\r\r\nOK\r\n";
1137        assert!(matches!(
1138            at_final_line_complete(buf, AtResultFormat::Verbose),
1139            Some(ExchangeMatch::Ok)
1140        ));
1141        assert!(at_final_line_complete(b"AT\r\r\nO", AtResultFormat::Verbose).is_none());
1142    }
1143
1144    #[test]
1145    fn numeric_atv0_ok() {
1146        let buf = b"AT\r\r\n0\r\n";
1147        assert!(at_final_line_complete(buf, AtResultFormat::Verbose).is_none());
1148        assert!(matches!(
1149            at_final_line_complete(buf, AtResultFormat::Numeric),
1150            Some(ExchangeMatch::Ok)
1151        ));
1152    }
1153
1154    #[test]
1155    fn numeric_no_carrier_is_final() {
1156        assert!(matches!(
1157            at_final_line_complete(b"ATD\r\r\n3\r\n", AtResultFormat::Numeric),
1158            Some(ExchangeMatch::NoCarrier)
1159        ));
1160    }
1161
1162    #[test]
1163    fn numeric_one_is_intermediate_not_final() {
1164        assert!(at_final_line_complete(b"ATD\r\r\n1\r\n", AtResultFormat::Numeric).is_none());
1165        assert!(matches!(
1166            at_intermediate_line_complete(b"ATD\r\r\n1\r\n", AtResultFormat::Numeric),
1167            Some(ExchangeMatch::Intermediate { .. })
1168        ));
1169    }
1170
1171    #[test]
1172    fn prompt_is_intermediate_complete() {
1173        assert!(matches!(
1174            at_intermediate_line_complete(b"AT+CMGS=1\r\r\n>\r\n", AtResultFormat::Verbose),
1175            Some(ExchangeMatch::Intermediate { line }) if line == ">"
1176        ));
1177    }
1178
1179    #[test]
1180    fn connect_is_not_final() {
1181        assert!(
1182            at_final_line_complete(b"ATD123\r\r\nCONNECT\r\n", AtResultFormat::Verbose).is_none()
1183        );
1184        assert!(classify_intermediate_line("CONNECT 9600"));
1185    }
1186
1187    #[test]
1188    fn no_carrier_is_final() {
1189        assert!(matches!(
1190            at_final_line_complete(b"ATD\r\r\nNO CARRIER\r\n", AtResultFormat::Verbose),
1191            Some(ExchangeMatch::NoCarrier)
1192        ));
1193    }
1194
1195    #[test]
1196    fn derive_prefix_csq() {
1197        assert_eq!(
1198            derive_solicited_prefixes("AT+CSQ"),
1199            vec!["+CSQ:".to_string()]
1200        );
1201    }
1202
1203    #[test]
1204    fn derive_prefix_syscfg() {
1205        assert_eq!(
1206            derive_solicited_prefixes("AT^SYSCFG?"),
1207            vec!["^SYSCFG:".to_string()]
1208        );
1209    }
1210
1211    #[test]
1212    fn csq_is_solicited_not_urc() {
1213        let raw = b"AT+CSQ\r\r\n+CSQ: 20,99\r\nOK\r\n";
1214        let parsed = parse_at_response(raw, "AT+CSQ", &[], AtResultFormat::Verbose);
1215        assert_eq!(parsed.status, AtParseStatus::Ok);
1216        assert!(parsed.solicited_body.iter().any(|l| l.starts_with("+CSQ:")));
1217        assert!(parsed.urc_lines.is_empty());
1218    }
1219
1220    #[test]
1221    fn syscfg_solicited_with_derived_prefix() {
1222        let raw = b"AT^SYSCFG?\r\r\n^SYSCFG: 2,2,3FFFFFFF,1,1\r\nOK\r\n";
1223        let parsed = parse_at_response(raw, "AT^SYSCFG?", &[], AtResultFormat::Verbose);
1224        assert!(parsed
1225            .solicited_body
1226            .iter()
1227            .any(|l| l.starts_with("^SYSCFG:")));
1228        assert!(parsed.urc_lines.is_empty());
1229    }
1230
1231    #[test]
1232    fn creg_in_blob_classified_as_urc_before_echo() {
1233        let raw = b"+CREG: 1,2\r\nAT\r\r\nOK\r\n";
1234        let parsed = parse_at_response(raw, "AT", &[], AtResultFormat::Verbose);
1235        assert!(parsed.urc_lines.iter().any(|l| l.starts_with("+CREG:")));
1236    }
1237
1238    #[test]
1239    fn cme_error_text_status() {
1240        let raw = b"AT+FOO\r\r\n+CME ERROR: SIM not inserted\r\n";
1241        let parsed = parse_at_response(raw, "AT+FOO", &[], AtResultFormat::Verbose);
1242        assert_eq!(parsed.status, AtParseStatus::Cme);
1243    }
1244
1245    #[test]
1246    fn demux_emits_urc_before_echo() {
1247        let mut demux = ExchangeDemux::new("AT", &[]);
1248        let urc = demux.process_chunk(b"+CREG: 1\r\nAT\r\n");
1249        assert!(urc.iter().any(|l| l.starts_with("+CREG")));
1250    }
1251}