Skip to main content

serial_codec/sms/pdu/
encode.rs

1//! SMS-SUBMIT encoder with automatic alphabet choice and concatenation.
2
3use super::{
4    Alphabet, Dcs, Ie, PduError, Result, Udh, Validity, IEI_CONCAT_16, IEI_CONCAT_8,
5    IEI_LOCKING_SHIFT, IEI_SINGLE_SHIFT, TOA_ALPHANUMERIC, TOA_INTERNATIONAL, TOA_UNKNOWN,
6};
7use crate::sms::gsm7::{self, Language, Tables};
8use crate::sms::{hex_encode, ucs2};
9
10/// Message body.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Body {
13    Text(String),
14    /// 8-bit data (DCS 0x04), split by octets.
15    Binary(Vec<u8>),
16}
17
18impl Default for Body {
19    fn default() -> Self {
20        Body::Text(String::new())
21    }
22}
23
24/// Everything an SMS-SUBMIT needs. `SubmitRequest::new(to, text)` gives the usual
25/// defaults: no SMSC (modem's), auto alphabet, MR 0, no VP, no status report, no
26/// concatenation reference (set one for text that may not fit one part).
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct SubmitRequest {
29    /// SMSC address; `None` or empty → `00` (use the one stored in the modem).
30    pub smsc: Option<String>,
31    /// TOA for the SMSC; default `0x91` with a leading `+`, else `0x81`.
32    pub smsc_toa: Option<u8>,
33    /// `+` + digits (international), digits / `*` / `#` (unknown TON) or text
34    /// (alphanumeric, up to 11 GSM 7-bit characters). Set `destination_toa` to send the
35    /// semi-octets `a b c` as digits.
36    pub destination: String,
37    /// TOA override for the destination (e.g. [`super::TOA_NATIONAL`]). TON 5 forces
38    /// alphanumeric encoding.
39    pub destination_toa: Option<u8>,
40    pub body: Body,
41    /// Force an alphabet; `None` picks GSM 7-bit, then national tables from
42    /// `locking_shift`/`single_shift` (if that avoids UCS2), then UCS2.
43    pub alphabet: Option<Alphabet>,
44    /// National locking shift tables the recipient supports (23.038 §6.2.1.2.4).
45    pub locking_shift: Vec<Language>,
46    /// National single shift tables the recipient supports.
47    pub single_shift: Vec<Language>,
48    pub class: Option<u8>,
49    pub pid: u8,
50    pub message_ref: u8,
51    pub status_report: bool,
52    pub reject_duplicates: bool,
53    pub reply_path: bool,
54    pub validity: Option<Validity>,
55    /// Concatenation reference (23.040 §9.2.3.24.1), required when the message needs
56    /// more than one part: [`encode_submit`] returns [`PduError::MissingConcatRef`]
57    /// without it. It must differ between long messages to the same recipient that may
58    /// be in flight together, or the receiver can merge their parts; senders keep a
59    /// counter (modulo 256, or 65536 with `concat_16bit`) per recipient or globally.
60    /// Values above 255 imply a 16-bit reference IE.
61    pub concat_ref: Option<u16>,
62    pub concat_16bit: bool,
63}
64
65impl SubmitRequest {
66    pub fn new(destination: impl Into<String>, text: impl Into<String>) -> Self {
67        SubmitRequest {
68            destination: destination.into(),
69            body: Body::Text(text.into()),
70            ..Default::default()
71        }
72    }
73}
74
75/// Encode a TP address field (length counts digits, not `+`). An empty value (or a
76/// lone `+`) is `Invalid("address")`.
77pub fn encode_address(value: &str, toa: Option<u8>) -> Result<Vec<u8>> {
78    let digits = value.strip_prefix('+');
79    if digits.unwrap_or(value).is_empty() {
80        return Err(PduError::Invalid("address"));
81    }
82    let toa = toa.unwrap_or(match digits {
83        Some(_) => TOA_INTERNATIONAL,
84        None if !value.is_empty()
85            && value
86                .bytes()
87                .all(|b| b.is_ascii_digit() || b == b'*' || b == b'#') =>
88        {
89            TOA_UNKNOWN
90        }
91        None => TOA_ALPHANUMERIC,
92    });
93    if super::Ton::from_toa(toa) == super::Ton::Alphanumeric {
94        let septets = gsm7::encode(value, Tables::DEFAULT).map_err(PduError::Unencodable)?;
95        let semi = (septets.len() * 7).div_ceil(4);
96        if semi > 20 {
97            return Err(PduError::Invalid("address length"));
98        }
99        let mut out = vec![semi as u8, toa];
100        out.extend(gsm7::pack(&septets, 0));
101        return Ok(out);
102    }
103    let digits = digits.unwrap_or(value);
104    if digits.chars().count() > 20 {
105        return Err(PduError::Invalid("address length"));
106    }
107    let body = super::encode_semi_octets(digits).map_err(PduError::Unencodable)?;
108    let mut out = vec![digits.len() as u8, toa];
109    out.extend(body);
110    Ok(out)
111}
112
113/// Encode the SMSC prefix (length counts TOA + octets); `None`/empty → `[0]`.
114pub fn encode_smsc(value: Option<&str>, toa: Option<u8>) -> Result<Vec<u8>> {
115    let Some(v) = value.filter(|v| !v.is_empty()) else {
116        return Ok(vec![0]);
117    };
118    let digits = v.strip_prefix('+');
119    let toa = toa.unwrap_or(if digits.is_some() {
120        TOA_INTERNATIONAL
121    } else {
122        TOA_UNKNOWN
123    });
124    let digits = digits.unwrap_or(v);
125    if digits.chars().count() > 20 {
126        return Err(PduError::Invalid("SMSC length"));
127    }
128    let body = super::encode_semi_octets(digits).map_err(PduError::Unencodable)?;
129    let mut out = vec![body.len() as u8 + 1, toa];
130    out.extend(body);
131    Ok(out)
132}
133
134/// One encodable unit: a character (1–2 septets / 2–4 UCS2 octets) or a data octet.
135/// Parts are split between units only, so ESC pairs and surrogate pairs stay whole.
136struct Units {
137    alphabet: Alphabet,
138    tables: Tables,
139    units: Vec<Vec<u8>>,
140}
141
142fn units(req: &SubmitRequest) -> Result<Units> {
143    let text = match &req.body {
144        Body::Binary(b) => {
145            if req.alphabet.is_some_and(|a| a != Alphabet::Bit8) {
146                return Err(PduError::Invalid("alphabet for binary body"));
147            }
148            return Ok(Units {
149                alphabet: Alphabet::Bit8,
150                tables: Tables::DEFAULT,
151                units: b.iter().map(|&x| vec![x]).collect(),
152            });
153        }
154        Body::Text(t) => t,
155    };
156    let gsm = |tables: Tables| -> Result<Units> {
157        let units = text
158            .chars()
159            .map(|c| {
160                gsm7::encode_char(c, tables)
161                    .map(|(s, n)| s[..n].to_vec())
162                    .ok_or(PduError::Unencodable(c))
163            })
164            .collect::<Result<_>>()?;
165        Ok(Units {
166            alphabet: Alphabet::Gsm7,
167            tables,
168            units,
169        })
170    };
171    let ucs = || Units {
172        alphabet: Alphabet::Ucs2,
173        tables: Tables::DEFAULT,
174        units: text
175            .chars()
176            .map(|c| ucs2::encode(c.encode_utf8(&mut [0; 4])))
177            .collect(),
178    };
179    match req.alphabet {
180        Some(Alphabet::Gsm7) => {
181            let tables = gsm7::choose_tables(text, &req.locking_shift, &req.single_shift)
182                .ok_or_else(|| first_unencodable(text))?;
183            gsm(tables)
184        }
185        Some(Alphabet::Ucs2) => Ok(ucs()),
186        Some(Alphabet::Bit8) => Err(PduError::Invalid("alphabet for text body")),
187        None => Ok(
188            match gsm7::choose_tables(text, &req.locking_shift, &req.single_shift) {
189                Some(t) => gsm(t)?,
190                None => ucs(),
191            },
192        ),
193    }
194}
195
196/// Error for a forced GSM 7-bit text no table pair can encode: the first character
197/// outside the default tables (or `Invalid` when the conflict is between tables).
198fn first_unencodable(text: &str) -> PduError {
199    gsm7::encode(text, Tables::DEFAULT)
200        .err()
201        .map_or(PduError::Invalid("text"), PduError::Unencodable)
202}
203
204/// Capacity of one part in units of the alphabet's length (septets or octets) for a
205/// UDH of `udh_len` octets (UDHL included; 0 = no UDH).
206fn capacity(alphabet: Alphabet, udh_len: usize) -> usize {
207    match alphabet {
208        Alphabet::Gsm7 => 160 - (udh_len * 8).div_ceil(7),
209        _ => 140 - udh_len,
210    }
211}
212
213/// Encode an SMS-SUBMIT, splitting into concatenated parts when needed (which requires
214/// [`SubmitRequest::concat_ref`]).
215///
216/// Returns `(tpdu_len, hex)` per part: `tpdu_len` is the `<length>` for `AT+CMGS`
217/// (TPDU octets, SMSC prefix excluded); `hex` is the full PDU to send after the prompt.
218pub fn encode_submit(req: &SubmitRequest) -> Result<Vec<(usize, String)>> {
219    let u = units(req)?;
220    let smsc = encode_smsc(req.smsc.as_deref(), req.smsc_toa)?;
221    let da = encode_address(&req.destination, req.destination_toa)?;
222
223    let mut national = Vec::new();
224    if u.tables.single != Language::Default {
225        national.push(Ie {
226            id: IEI_SINGLE_SHIFT,
227            data: vec![u.tables.single.nli()],
228        });
229    }
230    if u.tables.locking != Language::Default {
231        national.push(Ie {
232            id: IEI_LOCKING_SHIFT,
233            data: vec![u.tables.locking.nli()],
234        });
235    }
236    let national_len = national.iter().map(|ie| 2 + ie.data.len()).sum::<usize>();
237
238    let total: usize = u.units.iter().map(Vec::len).sum();
239    let single_udh = if national.is_empty() {
240        0
241    } else {
242        1 + national_len
243    };
244    let concat_ref = req.concat_ref.unwrap_or(0);
245    let wide = req.concat_16bit || concat_ref > 255;
246    let parts: Vec<&[Vec<u8>]> = if total <= capacity(u.alphabet, single_udh) {
247        vec![&u.units[..]]
248    } else {
249        let cap = capacity(u.alphabet, 1 + national_len + if wide { 6 } else { 5 });
250        let mut parts = Vec::new();
251        let (mut start, mut used) = (0, 0);
252        for (i, unit) in u.units.iter().enumerate() {
253            if used + unit.len() > cap {
254                parts.push(&u.units[start..i]);
255                (start, used) = (i, 0);
256            }
257            used += unit.len();
258        }
259        parts.push(&u.units[start..]);
260        parts
261    };
262    if parts.len() > 255 {
263        return Err(PduError::TooManyParts);
264    }
265    if parts.len() > 1 && req.concat_ref.is_none() {
266        return Err(PduError::MissingConcatRef);
267    }
268
269    let (vpf, vp) = match &req.validity {
270        None => (0, Vec::new()),
271        Some(v @ Validity::Relative(r)) => (v.vpf(), vec![*r]),
272        Some(v @ Validity::Absolute(t)) => (
273            v.vpf(),
274            t.encode().ok_or(PduError::Invalid("TP-VP"))?.to_vec(),
275        ),
276        Some(v @ Validity::Enhanced(b)) => (v.vpf(), b.to_vec()),
277    };
278    let dcs = Dcs::encode(u.alphabet, req.class);
279    let n = parts.len();
280    let mut out = Vec::with_capacity(n);
281    for (i, part) in parts.iter().enumerate() {
282        let mut ies = Vec::new();
283        if n > 1 {
284            let (seq, total) = ((i + 1) as u8, n as u8);
285            ies.push(if wide {
286                let r = concat_ref.to_be_bytes();
287                Ie {
288                    id: IEI_CONCAT_16,
289                    data: vec![r[0], r[1], total, seq],
290                }
291            } else {
292                Ie {
293                    id: IEI_CONCAT_8,
294                    data: vec![concat_ref as u8, total, seq],
295                }
296            });
297        }
298        ies.extend(national.iter().cloned());
299        let udh = (!ies.is_empty())
300            .then(|| Udh { ies }.encode())
301            .transpose()?;
302        let udh_len = udh.as_ref().map_or(0, Vec::len);
303        let body: Vec<u8> = part.concat();
304        let (udl, packed) = match u.alphabet {
305            Alphabet::Gsm7 => {
306                let hdr_septets = (udh_len * 8).div_ceil(7);
307                let fill = (hdr_septets * 7 - udh_len * 8) as u8;
308                (hdr_septets + body.len(), gsm7::pack(&body, fill))
309            }
310            _ => (udh_len + body.len(), body),
311        };
312        let fo = 0x01
313            | u8::from(req.reject_duplicates) << 2
314            | vpf << 3
315            | u8::from(req.status_report) << 5
316            | u8::from(udh.is_some()) << 6
317            | u8::from(req.reply_path) << 7;
318        let mut tpdu = vec![fo, req.message_ref];
319        tpdu.extend_from_slice(&da);
320        tpdu.extend_from_slice(&[req.pid, dcs]);
321        tpdu.extend_from_slice(&vp);
322        tpdu.push(udl as u8);
323        tpdu.extend(udh.unwrap_or_default());
324        tpdu.extend(packed);
325        let mut pdu = smsc.clone();
326        pdu.extend_from_slice(&tpdu);
327        out.push((tpdu.len(), hex_encode(&pdu)));
328    }
329    Ok(out)
330}