Skip to main content

serial_codec/
ussd.rs

1//! USSD: 7-bit packing with the CBS/USSD padding rule (3GPP TS 23.038 §6.1.2.3.1),
2//! CBS data coding scheme (23.038 §5) and the `+CUSD` result line (TS 27.007 §7.15).
3
4use crate::sms::gsm7::{self, Tables};
5use crate::sms::{hex_decode, ucs2};
6use serde::{Deserialize, Serialize};
7
8const CR: u8 = 0x0D;
9
10/// Pack septets after `fill_bits` zero bits. When the last octet has 7 spare bits they
11/// carry a CR; when the text ends with CR exactly on an octet boundary another CR is
12/// appended, so the receiver can tell padding from data.
13pub fn pack_septets(septets: &[u8], fill_bits: u8) -> Vec<u8> {
14    let fill = usize::from(fill_bits % 8);
15    let mut s = septets.to_vec();
16    let bits = fill + 7 * s.len();
17    // 7 spare bits → CR fill; CR ending on an octet boundary → extra CR.
18    if bits % 8 == 1 || (bits % 8 == 0 && s.last() == Some(&CR)) {
19        s.push(CR);
20    }
21    gsm7::pack(&s, fill_bits)
22}
23
24/// Unpack every septet after `fill_bits`, dropping the padding CR (7 spare bits) and
25/// the extra CR added after a CR that ended on an octet boundary.
26pub fn unpack_septets(octets: &[u8], fill_bits: u8) -> Vec<u8> {
27    let fill = usize::from(fill_bits % 8);
28    let n = gsm7::septets_in(octets.len(), fill_bits);
29    let mut s = gsm7::unpack(octets, fill_bits, n);
30    let exact = fill + 7 * n == 8 * octets.len();
31    let boundary_before_last = n > 0 && (fill + 7 * (n - 1)) % 8 == 0;
32    let doubled = s.len() >= 2 && s[s.len() - 2] == CR && boundary_before_last;
33    if s.last() == Some(&CR) && (exact || doubled) {
34        s.pop();
35    }
36    s
37}
38
39/// Text → packed USSD string (GSM 7-bit default alphabet).
40pub fn encode(text: &str) -> Result<Vec<u8>, char> {
41    Ok(pack_septets(&gsm7::encode(text, Tables::DEFAULT)?, 0))
42}
43
44/// Packed USSD string → text (GSM 7-bit default alphabet).
45pub fn decode_gsm7(octets: &[u8]) -> String {
46    gsm7::decode(&unpack_septets(octets, 0), Tables::DEFAULT)
47}
48
49/// What a CBS/USSD data coding scheme says about the payload (23.038 §5).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct CbsDcs {
52    pub raw: u8,
53    pub alphabet: crate::sms::pdu::Alphabet,
54    pub compressed: bool,
55    /// The payload starts with a language indication (DCS 0x10: two characters + CR
56    /// in GSM 7-bit; DCS 0x11: two GSM 7-bit characters packed into two octets).
57    pub language_prefix: bool,
58    /// Reserved value, decoded as GSM 7-bit as the spec requires.
59    pub reserved: bool,
60}
61
62impl CbsDcs {
63    /// ISO 639-1 language named by the DCS itself (23.038 §5: group 0000 languages 0–14,
64    /// group 0010 values 0–4). `None` for "unspecified", reserved values and the
65    /// 0x10/0x11 forms, whose language is in the payload.
66    pub fn language(&self) -> Option<&'static str> {
67        const G0: [&str; 15] = [
68            "de", "en", "it", "fr", "es", "nl", "sv", "da", "pt", "fi", "no", "el", "tr", "hu",
69            "pl",
70        ];
71        const G2: [&str; 5] = ["cs", "he", "ar", "ru", "is"];
72        let table: &[&str] = match self.raw >> 4 {
73            0 => &G0,
74            2 => &G2,
75            _ => return None,
76        };
77        table.get(usize::from(self.raw & 0x0F)).copied()
78    }
79
80    pub fn decode(raw: u8) -> Self {
81        use crate::sms::pdu::Alphabet::*;
82        let mut d = CbsDcs {
83            raw,
84            alphabet: Gsm7,
85            compressed: false,
86            language_prefix: false,
87            reserved: false,
88        };
89        let alpha = |bits: u8| match bits & 3 {
90            0 => (Gsm7, false),
91            1 => (Bit8, false),
92            2 => (Ucs2, false),
93            _ => (Gsm7, true),
94        };
95        match raw >> 4 {
96            0x0 => {}
97            0x1 => match raw {
98                0x10 => d.language_prefix = true,
99                0x11 => (d.alphabet, d.language_prefix) = (Ucs2, true),
100                _ => d.reserved = true,
101            },
102            0x2 => d.reserved = raw > 0x24,
103            0x4..=0x7 => {
104                d.compressed = raw & 0x20 != 0;
105                (d.alphabet, d.reserved) = alpha(raw >> 2);
106            }
107            0x9 => (d.alphabet, d.reserved) = alpha(raw >> 2),
108            0xF => {
109                if raw & 0x04 != 0 {
110                    d.alphabet = Bit8;
111                }
112            }
113            _ => d.reserved = true,
114        }
115        d
116    }
117}
118
119/// A decoded USSD string.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct UssdText {
122    pub text: String,
123    /// ISO 639 language: from a DCS 0x10/0x11 prefix, else the one the DCS names
124    /// ([`CbsDcs::language`]).
125    pub language: Option<String>,
126}
127
128/// Decode a network USSD string (the octets, not hex) per its DCS. 8-bit data is read
129/// as ISO-8859-1; compressed data (23.042) is not supported and returns `None`.
130pub fn decode(payload: &[u8], dcs: u8) -> Option<UssdText> {
131    use crate::sms::pdu::Alphabet::*;
132    let d = CbsDcs::decode(dcs);
133    if d.compressed {
134        return None;
135    }
136    let (language, body): (Option<String>, &[u8]) = match (d.language_prefix, d.alphabet) {
137        (true, Ucs2) => {
138            let lang = payload.get(..2)?;
139            let l = gsm7::decode(&gsm7::unpack(lang, 0, 2), Tables::DEFAULT);
140            (Some(l), &payload[2..])
141        }
142        _ => (None, payload),
143    };
144    let mut text = match d.alphabet {
145        Gsm7 => decode_gsm7(body),
146        Ucs2 => ucs2::decode(body),
147        Bit8 => body.iter().map(|&b| char::from(b)).collect(),
148    };
149    let mut language = language.or_else(|| d.language().map(String::from));
150    if d.language_prefix && d.alphabet == Gsm7 {
151        let mut it = text.chars();
152        let lang: String = it.by_ref().take(2).collect();
153        if it.next() == Some('\r') {
154            language = Some(lang);
155            text = it.collect();
156        }
157    }
158    Some(UssdText { text, language })
159}
160
161/// `<m>` of `+CUSD` (27.007 §7.15).
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum CusdStatus {
165    /// 0: no further user action required.
166    Done,
167    /// 1: further user action required (session stays open).
168    ActionRequired,
169    /// 2: terminated by the network.
170    Terminated,
171    /// 3: another local client has responded.
172    OtherClient,
173    /// 4: operation not supported.
174    NotSupported,
175    /// 5: network timeout.
176    Timeout,
177    Other(u8),
178}
179
180impl CusdStatus {
181    pub fn from_code(m: u8) -> Self {
182        match m {
183            0 => Self::Done,
184            1 => Self::ActionRequired,
185            2 => Self::Terminated,
186            3 => Self::OtherClient,
187            4 => Self::NotSupported,
188            5 => Self::Timeout,
189            n => Self::Other(n),
190        }
191    }
192
193    pub fn session_active(self) -> bool {
194        self == Self::ActionRequired
195    }
196}
197
198/// One `+CUSD: <m>[,<str>[,<dcs>]]` line.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct Cusd {
201    pub status: CusdStatus,
202    /// `<str>` as sent (quotes removed, `\"` and `\\` unescaped), not decoded; see
203    /// [`Cusd::text`], [`Cusd::text_as`] and [`Cusd::text_packed_hex`].
204    pub raw: Option<String>,
205    /// `<dcs>` when it is a number 0–255.
206    pub dcs: Option<u8>,
207    /// A `<dcs>` that is present but not a number 0–255 (e.g. `300`), as sent. `dcs` is
208    /// then `None` and the text is read with the default DCS 0.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub invalid_dcs: Option<String>,
211}
212
213/// Parse one logical `+CUSD` line (prefix case-insensitive). The string may hold
214/// commas, CR/LF and quotes; the closing quote is the last one on the line. A trailing
215/// `OK` glued to the line after the string or the DCS is ignored; an `OK` inside an
216/// unclosed string is kept as text. `None` if this is not a `+CUSD` line.
217pub fn parse_cusd(line: &str) -> Option<Cusd> {
218    let line = line.trim();
219    let rest = line
220        .get(..6)
221        .filter(|p| p.eq_ignore_ascii_case("+CUSD:"))
222        .map(|_| line[6..].trim_start())?;
223    let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
224    let status = CusdStatus::from_code(rest[..digits].parse().ok()?);
225    let mut cusd = Cusd {
226        status,
227        raw: None,
228        dcs: None,
229        invalid_dcs: None,
230    };
231    let tail = rest[digits..].trim();
232    if strip_ok(tail).is_empty() {
233        return Some(cusd);
234    }
235    let tail = tail.strip_prefix(',')?.trim_start();
236    let (raw, after) = if let Some(q) = tail.strip_prefix('"') {
237        match q.rfind('"') {
238            Some(end) => (unescape(&q[..end]), q[end + 1..].trim()),
239            None => (unescape(q), ""),
240        }
241    } else {
242        // Unquoted: the last `,<digits>` is the DCS.
243        let tail = strip_ok(tail);
244        match tail.rfind(',') {
245            Some(i) if is_number(tail[i + 1..].trim()) => {
246                (tail[..i].trim().to_string(), &tail[i..])
247            }
248            _ => (tail.to_string(), ""),
249        }
250    };
251    cusd.raw = Some(raw);
252    match strip_ok(after).strip_prefix(',').map(str::trim) {
253        None | Some("") => {}
254        Some(d) => match d.parse::<u8>() {
255            Ok(v) => cusd.dcs = Some(v),
256            Err(_) => cusd.invalid_dcs = Some(d.to_string()),
257        },
258    }
259    Some(cusd)
260}
261
262fn is_number(s: &str) -> bool {
263    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
264}
265
266/// Drop an `OK` glued to the end of the line (after the DCS, a quote or nothing).
267fn strip_ok(s: &str) -> &str {
268    let t = s.trim_end();
269    if let Some(head) = t.strip_suffix("OK").or_else(|| t.strip_suffix("ok")) {
270        let h = head.trim_end();
271        if h.is_empty() || h.ends_with('"') || h.ends_with(|c: char| c.is_ascii_digit()) {
272            return h;
273        }
274    }
275    t
276}
277
278fn unescape(s: &str) -> String {
279    let mut out = String::with_capacity(s.len());
280    let mut it = s.chars().peekable();
281    while let Some(c) = it.next() {
282        match (c, it.peek()) {
283            ('\\', Some(&n @ ('"' | '\\'))) => {
284                out.push(n);
285                it.next();
286            }
287            _ => out.push(c),
288        }
289    }
290    out
291}
292
293/// TE character set chosen with `AT+CSCS` (27.007 §5.5). With a GSM 7-bit `<dcs>` the
294/// modem converts `<str>` into it (27.007 §7.15); the parser cannot see `+CSCS`, so the
295/// caller says which one is active.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum Cscs {
299    /// `"GSM"`: the characters themselves; `<str>` is returned as sent.
300    #[default]
301    Gsm,
302    /// `"IRA"` and the other text sets (`"8859-1"`, `"PCCP437"`, `"UTF-8"`): as sent.
303    Ira,
304    /// `"UCS2"`: hex of UTF-16BE code units (`"0031"` → `"1"`).
305    Ucs2,
306    /// `"HEX"`: one hex octet per unpacked GSM 7-bit character (`"4869"` → `"Hi"`).
307    Hex,
308}
309
310impl Cusd {
311    /// Decoded text with `+CSCS="GSM"` or `"IRA"` (the default): same as
312    /// `text_as(Cscs::Gsm)`.
313    pub fn text(&self) -> Option<String> {
314        self.text_as(Cscs::Gsm)
315    }
316
317    /// Decoded text per 27.007 §7.15 with the given TE character set.
318    ///
319    /// - 8-bit / UCS2 `<dcs>`: `<str>` is the octets in hex whatever `+CSCS` is; they are
320    ///   decoded with [`decode`]. A `<str>` that is not hex is returned as sent.
321    /// - GSM 7-bit `<dcs>` (or none: 27.007 default 0): `<str>` is in `cscs`. `None` when
322    ///   it is not valid hex for [`Cscs::Ucs2`] / [`Cscs::Hex`].
323    ///
324    /// `None` without `<str>` and for compressed data (23.042, not supported). Firmware
325    /// that sends packed septets in hex under a GSM 7-bit DCS needs
326    /// [`Cusd::text_packed_hex`].
327    pub fn text_as(&self, cscs: Cscs) -> Option<String> {
328        use crate::sms::pdu::Alphabet::*;
329        let raw = self.raw.as_deref()?;
330        let d = CbsDcs::decode(self.dcs.unwrap_or(0));
331        if d.compressed {
332            return None;
333        }
334        match (d.alphabet, cscs) {
335            (Ucs2 | Bit8, _) => match hex_decode(raw) {
336                Some(b) => decode(&b, d.raw).map(|t| t.text),
337                None => Some(raw.to_string()),
338            },
339            (Gsm7, Cscs::Gsm | Cscs::Ira) => Some(raw.to_string()),
340            (Gsm7, Cscs::Ucs2) => hex_decode(raw).map(|b| ucs2::decode(&b)),
341            (Gsm7, Cscs::Hex) => hex_decode(raw).map(|b| gsm7::decode(&b, Tables::DEFAULT)),
342        }
343    }
344
345    /// Not standard: some firmware (Huawei E160 and others, seen in gammu's dumps) sends
346    /// the network's packed 7-bit USSD octets in hex under a GSM 7-bit `<dcs>` instead of
347    /// converting them to the TE character set. Decode that (`"AA58ACA6AA8D1A"` →
348    /// `"*115*5#"`). `None` when `<str>` is not hex; with an 8-bit / UCS2 `<dcs>` this is
349    /// [`Cusd::text`].
350    pub fn text_packed_hex(&self) -> Option<String> {
351        let d = CbsDcs::decode(self.dcs.unwrap_or(0));
352        if d.alphabet != crate::sms::pdu::Alphabet::Gsm7 {
353            return self.text();
354        }
355        decode(&hex_decode(self.raw.as_deref()?)?, d.raw).map(|t| t.text)
356    }
357}