Skip to main content

at_parser_rs/
lib.rs

1/***************************************************************************
2 *
3 * AT Command Parser
4 * Copyright (C) 2026 Antonio Salsi <passy.linux@zresa.it>
5 *
6 * This library is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU Lesser General Public
8 * License as published by the Free Software Foundation; either
9 * version 2.1 of the License, or (at your option) any later version.
10 *
11 * This library is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 * Lesser General Public License for more details.
15 *
16 * You should have received a copy of the GNU Lesser General Public
17 * License along with this library; if not, see <https://www.gnu.org/licenses/>.
18 *
19 ***************************************************************************/
20
21//! AT Command Parser Library
22//!
23//! This library provides a flexible parser for AT commands, commonly used in
24//! embedded systems and communication devices. It supports `no_std` environments.
25//!
26//! # Architecture
27//!
28//! The library is built around three core components:
29//!
30//! - **[`AtParser`](parser::AtParser)** - The main parser that processes AT command strings
31//! - **[`AtContext`](context::AtContext)** - Trait for implementing command handlers
32//! - **[`Args`]** - Structure for accessing command arguments
33//!
34//! # Command Forms
35//!
36//! Supports all standard AT command forms:
37//! - `AT+CMD` - Execute (action without parameters)
38//! - `AT+CMD?` - Query (get current value/state)
39//! - `AT+CMD=?` - Test (get supported values/ranges)
40//! - `AT+CMD=<args>` - Set (configure with parameters)
41//!
42//! # Quick Start
43//!
44//! ```rust,no_run
45//! use at_parser_rs::context::AtContext;
46//! use at_parser_rs::parser::AtParser;
47//! use at_parser_rs::{Args, AtResult, AtError};
48//! use osal_rs::utils::Bytes;
49//!
50//! const SIZE: usize = 64;
51//!
52//! // 1. Define a command handler
53//! struct EchoModule { echo: bool }
54//!
55//! impl AtContext<SIZE> for EchoModule {
56//!     fn query(&mut self) -> AtResult<SIZE> {
57//!         if self.echo { Ok(Bytes::from_str("1")) } else { Ok(Bytes::from_str("0")) }
58//!     }
59//!     
60//!     fn set(&mut self, args: Args) -> AtResult<SIZE> {
61//!         let value = args.get(0).ok_or(AtError::InvalidArgs)?;
62//!         match value.as_ref() {
63//!             "0" => { self.echo = false; Ok(Bytes::from_str("OK")) }
64//!             "1" => { self.echo = true; Ok(Bytes::from_str("OK")) }
65//!             _ => Err(AtError::InvalidArgs),
66//!         }
67//!     }
68//! }
69//!
70//! // 2. Create parser and register commands
71//! let mut echo = EchoModule { echo: false };
72//! let mut parser: AtParser<EchoModule, SIZE> = AtParser::new();
73//!
74//! let commands: &mut [(&str, &mut dyn AtContext<SIZE>)] = &mut [
75//!     ("AT+ECHO", &mut echo),
76//! ];
77//! parser.set_commands(commands);
78//!
79//! // 3. Execute commands
80//! parser.execute("AT+ECHO=1");  // Set echo on
81//! parser.execute("AT+ECHO?");   // Query current state
82//! ```
83//!
84//! # Features
85//!
86//! - **`freertos`** (default) - Enable FreeRTOS support via osal-rs
87//! - **`posix`** - Enable POSIX support via osal-rs
88//! - **`std`** - Enable standard library support via osal-rs
89//! - **`disable_panic`** - Pass-through feature to osal-rs for panic handling
90//!
91//! # Thread Safety
92//!
93//! The library can be used in single-threaded (bare-metal) or multi-threaded (RTOS)
94//! environments. For RTOS, use appropriate synchronization primitives around
95//! command handlers (e.g., `Mutex<RefCell<Handler>>`).
96
97#![no_std]
98
99extern crate alloc;
100extern crate osal_rs;
101
102use core::option::Option;
103use core::result::Result;
104
105use alloc::borrow::Cow;
106use alloc::string::String;
107use osal_rs::utils::Bytes;
108
109pub mod context;
110pub mod parser;
111
112
113/// Error types that can occur during AT command processing
114#[derive(Debug)]
115pub enum AtError<'a> {
116    /// The command is not recognized
117    UnknownCommand,
118    /// The command is recognized but not supported
119    NotSupported,
120    /// The command arguments are invalid
121    InvalidArgs,
122    /// Unhandled error with description
123    Unhandled(&'a str),
124    /// Unhandled error with description owned
125    UnhandledOwned(String)
126}
127
128/// Result type for AT command operations
129/// Returns either a `Bytes<SIZE>` response buffer or an `AtError`
130pub type AtResult<'a, const SIZE: usize> = Result<Bytes<SIZE>, AtError<'a>>;
131
132/// Structure holding the arguments passed to an AT command
133pub struct Args<'a> {
134    /// Raw argument string (comma-separated values)
135    pub raw: &'a str,
136}
137
138impl<'a> Args<'a> {
139    /// Get an argument by index (0-based)
140    /// Arguments are separated by commas, except when they are inside
141    /// double-quoted strings.
142    ///
143    /// When an argument is wrapped in double quotes, the outer quotes are
144    /// removed from the returned value and escaped quotes (`\"`) are
145    /// decoded to `"`.
146    pub fn get(&self, index: usize) -> Option<Cow<'a, str>> {
147        let (arg, quoted) = self.find(index)?;
148
149        if quoted {
150            Some(Self::decode_quoted(arg))
151        } else {
152            Some(Cow::Borrowed(arg))
153        }
154    }
155
156    /// Get an argument by index without decoding escape sequences.
157    ///
158    /// Quoted arguments are still returned without the surrounding quotes.
159    pub fn get_raw(&self, index: usize) -> Option<&'a str> {
160        self.find(index).map(|(arg, _)| arg)
161    }
162
163    /// Backward-compatible alias for [`Args::get`].
164    pub fn get_string(&self, index: usize) -> Option<Cow<'a, str>> {
165        self.get(index)
166    }
167
168    fn find(&self, index: usize) -> Option<(&'a str, bool)> {
169        let mut current_index = 0;
170        let mut start = 0;
171        let mut in_quotes = false;
172        let mut escaped = false;
173
174        for (offset, ch) in self.raw.char_indices() {
175            if escaped {
176                escaped = false;
177                continue;
178            }
179
180            if in_quotes {
181                match ch {
182                    '\\' => escaped = true,
183                    '"' => in_quotes = false,
184                    _ => {}
185                }
186                continue;
187            }
188
189            match ch {
190                '"' => in_quotes = true,
191                ',' => {
192                    if current_index == index {
193                        return Some(Self::normalize(&self.raw[start..offset]));
194                    }
195
196                    current_index += 1;
197                    start = offset + ch.len_utf8();
198                }
199                _ => {}
200            }
201        }
202
203        if current_index == index {
204            Some(Self::normalize(&self.raw[start..]))
205        } else {
206            None
207        }
208    }
209
210    fn normalize(arg: &'a str) -> (&'a str, bool) {
211        if let Some(inner) = arg.strip_prefix('"').and_then(|value| value.strip_suffix('"')) {
212            (inner, true)
213        } else {
214            (arg, false)
215        }
216    }
217
218    fn decode_quoted(arg: &'a str) -> Cow<'a, str> {
219        if !arg.contains('\\') {
220            return Cow::Borrowed(arg);
221        }
222
223        let mut decoded = String::new();
224        let mut escaped = false;
225
226        for ch in arg.chars() {
227            if escaped {
228                match ch {
229                    '"' | '\\' => decoded.push(ch),
230                    _ => {
231                        decoded.push('\\');
232                        decoded.push(ch);
233                    }
234                }
235                escaped = false;
236                continue;
237            }
238
239            if ch == '\\' {
240                escaped = true;
241                continue;
242            }
243
244            decoded.push(ch);
245        }
246
247        if escaped {
248            decoded.push('\\');
249        }
250
251        Cow::Owned(decoded)
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::Args;
258
259    #[test]
260    fn get_splits_plain_arguments() {
261        let args = Args { raw: "foo,bar,baz" };
262
263        assert_eq!(args.get(0).as_deref(), Some("foo"));
264        assert_eq!(args.get(1).as_deref(), Some("bar"));
265        assert_eq!(args.get(2).as_deref(), Some("baz"));
266        assert_eq!(args.get(3), None);
267    }
268
269    #[test]
270    fn get_keeps_commas_inside_quoted_arguments() {
271        let args = Args { raw: "i,\"ciao, sono antonio\",secret" };
272
273        assert_eq!(args.get(0).as_deref(), Some("i"));
274        assert_eq!(args.get(1).as_deref(), Some("ciao, sono antonio"));
275        assert_eq!(args.get(2).as_deref(), Some("secret"));
276    }
277
278    #[test]
279    fn get_decodes_escaped_quotes() {
280        let args = Args { raw: r#"i,"ciao, sono \"antonio\"",mysecretpassword"# };
281
282        assert_eq!(args.get_raw(1), Some(r#"ciao, sono \"antonio\""#));
283        assert_eq!(args.get(1).as_deref(), Some("ciao, sono \"antonio\""));
284        assert_eq!(args.get(2).as_deref(), Some("mysecretpassword"));
285    }
286
287    #[test]
288    fn get_handles_empty_arguments() {
289        let args = Args { raw: "first,,\"\",last" };
290
291        assert_eq!(args.get(0).as_deref(), Some("first"));
292        assert_eq!(args.get(1).as_deref(), Some(""));
293        assert_eq!(args.get(2).as_deref(), Some(""));
294        assert_eq!(args.get(3).as_deref(), Some("last"));
295    }
296}
297
298
299/// Wraps a value in double-quote characters (`"`).
300///
301/// Expands to a string literal `"\"<value>\""` suitable for use inside
302/// [`at_response!`] arguments when the protocol
303/// requires quoted strings.
304///
305/// # Syntax
306///
307/// ```rust,ignore
308/// at_quoted!(value)
309/// ```
310///
311/// # Examples
312///
313/// ```rust,no_run
314/// use at_parser_rs::at_quoted;
315///
316/// let q = at_quoted!("hello");   // → `"hello"`
317/// let q = at_quoted!(42);        // → `"42"`
318/// ```
319///
320/// Inside an AT response:
321///
322/// ```rust,no_run
323/// use at_parser_rs::{at_response, at_quoted};
324///
325/// const SIZE: usize = 64;
326/// let name = "world";
327/// let resp = at_response!(SIZE, "+CMD: "; at_quoted!(name));
328/// // resp contains: +CMD: "world"
329/// ```
330#[macro_export]
331macro_rules! at_quoted {
332    ($val:expr) => {
333        ::core::format_args!("\"{}\"", $val)
334    };
335}
336
337/// Macro to format an AT response with 1–6 comma-separated parameters.
338///
339/// Constructs an [`osal_rs::utils::Bytes`] buffer by formatting the given
340/// prefix string (`AT_RESP`) followed by the arguments separated by commas.
341///
342/// # Syntax
343///
344/// ```rust,ignore
345/// at_response!(SIZE, AT_RESP; arg1, arg2, ..., arg6)
346/// ```
347///
348/// - `SIZE` — `const usize` for the response buffer capacity (must match the
349///   capacity used by the surrounding [`AtContext`](crate::context::AtContext) impl)
350/// - `AT_RESP` — the AT response prefix string literal (e.g. `"+ECHO: "`)
351/// - `arg1..arg6` — values to append, comma-separated; any type implementing
352///   [`core::fmt::Display`] is accepted, including [`at_quoted!`] expressions
353///
354/// # Examples
355///
356/// ```rust,no_run
357/// use at_parser_rs::at_response;
358///
359/// const SIZE: usize = 64;
360///
361/// // Single boolean argument
362/// let resp = at_response!(SIZE, "+ECHO: "; 1u8);
363/// // buffer: "+ECHO: 1"
364///
365/// // Two arguments (state and brightness)
366/// let resp = at_response!(SIZE, "+LED: "; 1u8, 75u8);
367/// // buffer: "+LED: 1,75"
368///
369/// // Three arguments
370/// let resp = at_response!(SIZE, "+NET: "; "192.168.1.1", 8080u16, 1u8);
371/// // buffer: "+NET: 192.168.1.1,8080,1"
372/// ```
373///
374/// Using [`at_quoted!`] inside the response:
375///
376/// ```rust,no_run
377/// use at_parser_rs::{at_response, at_quoted};
378///
379/// const SIZE: usize = 64;
380/// let ssid = "MyNetwork";
381/// let resp = at_response!(SIZE, "+WIFI: "; at_quoted!(ssid), -70i8);
382/// // buffer: +WIFI: "MyNetwork",-70
383/// ```
384#[macro_export]
385macro_rules! at_response {
386    ($size:expr, $at_resp:expr; $a1:expr) => {{
387        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
388        response.format(format_args!("{}{}", $at_resp, $a1));
389        response
390    }};
391    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr) => {{
392        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
393        response.format(format_args!("{}{},{}", $at_resp, $a1, $a2));
394        response
395    }};
396    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr, $a3:expr) => {{
397        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
398        response.format(format_args!("{}{},{},{}", $at_resp, $a1, $a2, $a3));
399        response
400    }};
401    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr, $a3:expr, $a4:expr) => {{
402        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
403        response.format(format_args!("{}{},{},{},{}", $at_resp, $a1, $a2, $a3, $a4));
404        response
405    }};
406    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr, $a3:expr, $a4:expr, $a5:expr) => {{
407        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
408        response.format(format_args!("{}{},{},{},{},{}", $at_resp, $a1, $a2, $a3, $a4, $a5));
409        response
410    }};
411    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr, $a3:expr, $a4:expr, $a5:expr, $a6:expr) => {{
412        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
413        response.format(format_args!("{}{},{},{},{},{},{}", $at_resp, $a1, $a2, $a3, $a4, $a5, $a6));
414        response
415    }};
416}
417
418
419
420/// Declares a static `COMMANDS` table mapping AT command strings to their handlers.
421///
422/// This macro expands into a `static COMMANDS` binding of type
423/// `&[(&'static str, &mut dyn AtContext<SIZE>)]`, which can then be passed to
424/// [`AtParser::set_commands`](crate::parser::AtParser::set_commands).
425///
426/// # Syntax
427///
428/// ```rust,ignore
429/// at_modules! {
430///     SIZE;
431///     "AT+CMD1" => HANDLER1,
432///     "AT+CMD2" => HANDLER2,
433/// }
434/// ```
435///
436/// - `SIZE` — `const usize` that defines the response buffer capacity (must match the
437///   capacity used by [`AtParser`](crate::parser::AtParser) and every [`AtContext`](crate::context::AtContext) impl).
438/// - `"AT+CMDn"` — the AT command string the parser will match against.
439/// - `HANDLERn` — a `static mut` variable that implements [`AtContext<SIZE>`](crate::context::AtContext).
440///
441/// # Safety
442///
443/// The macro uses an `unsafe` block internally to obtain `&mut` references to
444/// `static mut` items.  It is the caller's responsibility to ensure:
445///
446/// - **Single-threaded access only** — do not call this in a multi-threaded or
447///   RTOS context without external synchronisation.
448/// - **One call site** — the generated `COMMANDS` symbol is `static`; defining it
449///   more than once in the same scope will cause a compile error.
450///
451/// # Limitations
452///
453/// - All handlers must implement `AtContext` with the **same** `SIZE` constant.
454/// - The generated symbol is always named `COMMANDS`; rename it after expansion
455///   if you need multiple tables.
456///
457/// # Example — basic usage
458///
459/// ```rust,no_run
460/// use at_parser_rs::at_modules;
461/// use at_parser_rs::context::AtContext;
462/// use at_parser_rs::{Args, AtResult, AtError};
463/// use osal_rs::utils::Bytes;
464///
465/// const SIZE: usize = 64;
466///
467/// struct EchoModule { echo: bool }
468/// impl AtContext<SIZE> for EchoModule {
469///     fn query(&mut self) -> AtResult<SIZE> {
470///         Ok(Bytes::from_str(if self.echo { "1" } else { "0" }))
471///     }
472///     fn set(&mut self, args: Args) -> AtResult<SIZE> {
473///         let value = args.get(0).ok_or(AtError::InvalidArgs)?;
474///         match value.as_ref() {
475///             "0" => { self.echo = false; Ok(Bytes::from_str("OK")) }
476///             "1" => { self.echo = true;  Ok(Bytes::from_str("OK")) }
477///             _ => Err(AtError::InvalidArgs),
478///         }
479///     }
480/// }
481///
482/// struct ResetModule;
483/// impl AtContext<SIZE> for ResetModule {
484///     fn exec(&mut self) -> AtResult<SIZE> { Ok(Bytes::from_str("OK")) }
485/// }
486///
487/// static mut ECHO:  EchoModule  = EchoModule { echo: false };
488/// static mut RESET: ResetModule = ResetModule;
489///
490/// at_modules! {
491///     SIZE;
492///     "AT+ECHO" => ECHO,
493///     "AT+RST"  => RESET,
494/// }
495///
496/// // COMMANDS is now available in scope:
497/// // parser.set_commands(COMMANDS);
498/// ```
499///
500/// # Example — single handler
501///
502/// ```rust,no_run
503/// use at_parser_rs::at_modules;
504/// use at_parser_rs::context::AtContext;
505/// use at_parser_rs::{AtResult};
506/// use osal_rs::utils::Bytes;
507///
508/// const SIZE: usize = 32;
509///
510/// struct PingModule;
511/// impl AtContext<SIZE> for PingModule {
512///     fn exec(&mut self) -> AtResult<SIZE> { Ok(Bytes::from_str("PONG")) }
513/// }
514///
515/// static mut PING: PingModule = PingModule;
516///
517/// at_modules! {
518///     SIZE;
519///     "AT+PING" => PING,
520/// }
521/// ```
522///
523/// # Recommended alternative
524///
525/// For multi-type handler tables or when `static mut` is undesirable, prefer the
526/// explicit slice approach — it requires no `unsafe` at the call site and allows
527/// mixing handler types via trait objects:
528///
529/// ```rust,no_run
530/// use at_parser_rs::context::AtContext;
531///
532/// const SIZE: usize = 64;
533///
534/// let mut echo  = EchoModule  { echo: false };
535/// let mut reset = ResetModule;
536///
537/// let commands: &mut [(&str, &mut dyn AtContext<SIZE>)] = &mut [
538///     ("AT+ECHO", &mut echo),
539///     ("AT+RST",  &mut reset),
540/// ];
541/// parser.set_commands(commands);
542/// ```
543#[macro_export]
544macro_rules! at_modules {
545    (
546        $size:expr;
547        $( $name:expr => $module:ident ),* $(,)?
548    ) => {
549        static COMMANDS: &[(&'static str, &mut dyn AtContext<$size>)] = unsafe {
550            &[
551                $(
552                    ($name, &mut $module),
553                )*
554            ]
555        };
556    };
557}