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, at_response};
48//!
49//! const SIZE: usize = 64;
50//!
51//! // 1. Define a command handler
52//! struct EchoModule { echo: bool }
53//!
54//! impl AtContext<SIZE> for EchoModule {
55//!     fn query(&mut self, at_response: &'static str) -> AtResult<SIZE> {
56//!         Ok(at_response!(SIZE, at_response; if self.echo { 1u8 } else { 0u8 }))
57//!     }
58//!
59//!     fn set(&mut self, at_response: &'static str, args: Args) -> AtResult<SIZE> {
60//!         let value = args.get(0).ok_or((at_response, AtError::InvalidArgs))?;
61//!         match value.as_ref() {
62//!             "0" => { self.echo = false; Ok(at_response!(SIZE, at_response; "OK")) }
63//!             "1" => { self.echo = true;  Ok(at_response!(SIZE, at_response; "OK")) }
64//!             _ => Err((at_response, AtError::InvalidArgs)),
65//!         }
66//!     }
67//! }
68//!
69//! // 2. Create parser and register commands
70//! //    Each entry is (at_command, at_response_prefix, handler)
71//! let mut echo = EchoModule { echo: false };
72//! let mut parser: AtParser<EchoModule, SIZE> = AtParser::new();
73//!
74//! let commands: &mut [(&str, &str, &mut EchoModule)] = &mut [
75//!     ("AT+ECHO", "+ECHO: ", &mut echo),
76//! ];
77//! parser.set_commands(commands);
78//!
79//! // 3. Execute commands
80//! parser.execute("AT+ECHO=1");  // Set echo on  → Ok(("+ECHO: ", "OK"))
81//! parser.execute("AT+ECHO?");   // Query state  → Ok(("+ECHO: ", "1"))
82//! ```
83//!
84//! # Features
85//!
86//! - **`freertos`** (default) — Enable FreeRTOS support via osal-rs
87//! - **`posix`** — Enable POSIX (Linux/macOS) threading support via osal-rs
88//! - **`std`** — Enable standard library support via osal-rs
89//! - **`disable_panic`** — Pass-through feature to osal-rs; disables the built-in panic handler
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///
130/// Both the success and the error variant carry the AT response prefix string
131/// (`&'static str`) that was registered alongside the command, so callers can always
132/// reconstruct the full response line.
133///
134/// - `Ok((prefix, bytes))` — successful response with the AT prefix and payload
135/// - `Err((prefix, error))` — failure with the AT prefix and error kind
136pub type AtResult<'a, const SIZE: usize> = Result<(&'static str, Bytes<SIZE>), (&'static str, AtError<'a>)>;
137
138/// Structure holding the arguments passed to an AT command
139pub struct Args<'a> {
140    /// Raw argument string (comma-separated values)
141    pub raw: &'a str,
142}
143
144impl<'a> Args<'a> {
145    /// Get an argument by index (0-based)
146    /// Arguments are separated by commas, except when they are inside
147    /// double-quoted strings.
148    ///
149    /// When an argument is wrapped in double quotes, the outer quotes are
150    /// removed from the returned value and escaped quotes (`\"`) are
151    /// decoded to `"`.
152    pub fn get(&self, index: usize) -> Option<Cow<'a, str>> {
153        let (arg, quoted) = self.find(index)?;
154
155        if quoted {
156            Some(Self::decode_quoted(arg))
157        } else {
158            Some(Cow::Borrowed(arg))
159        }
160    }
161
162    /// Get an argument by index without decoding escape sequences.
163    ///
164    /// Quoted arguments are still returned without the surrounding quotes.
165    pub fn get_raw(&self, index: usize) -> Option<&'a str> {
166        self.find(index).map(|(arg, _)| arg)
167    }
168
169    /// Backward-compatible alias for [`Args::get`].
170    pub fn get_string(&self, index: usize) -> Option<Cow<'a, str>> {
171        self.get(index)
172    }
173
174    fn find(&self, index: usize) -> Option<(&'a str, bool)> {
175        let mut current_index = 0;
176        let mut start = 0;
177        let mut in_quotes = false;
178        let mut escaped = false;
179
180        for (offset, ch) in self.raw.char_indices() {
181            if escaped {
182                escaped = false;
183                continue;
184            }
185
186            if in_quotes {
187                match ch {
188                    '\\' => escaped = true,
189                    '"' => in_quotes = false,
190                    _ => {}
191                }
192                continue;
193            }
194
195            match ch {
196                '"' => in_quotes = true,
197                ',' => {
198                    if current_index == index {
199                        return Some(Self::normalize(&self.raw[start..offset]));
200                    }
201
202                    current_index += 1;
203                    start = offset + ch.len_utf8();
204                }
205                _ => {}
206            }
207        }
208
209        if current_index == index {
210            Some(Self::normalize(&self.raw[start..]))
211        } else {
212            None
213        }
214    }
215
216    fn normalize(arg: &'a str) -> (&'a str, bool) {
217        if let Some(inner) = arg.strip_prefix('"').and_then(|value| value.strip_suffix('"')) {
218            (inner, true)
219        } else {
220            (arg, false)
221        }
222    }
223
224    fn decode_quoted(arg: &'a str) -> Cow<'a, str> {
225        if !arg.contains('\\') {
226            return Cow::Borrowed(arg);
227        }
228
229        let mut decoded = String::new();
230        let mut escaped = false;
231
232        for ch in arg.chars() {
233            if escaped {
234                match ch {
235                    '"' | '\\' => decoded.push(ch),
236                    _ => {
237                        decoded.push('\\');
238                        decoded.push(ch);
239                    }
240                }
241                escaped = false;
242                continue;
243            }
244
245            if ch == '\\' {
246                escaped = true;
247                continue;
248            }
249
250            decoded.push(ch);
251        }
252
253        if escaped {
254            decoded.push('\\');
255        }
256
257        Cow::Owned(decoded)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::Args;
264
265    #[test]
266    fn get_splits_plain_arguments() {
267        let args = Args { raw: "foo,bar,baz" };
268
269        assert_eq!(args.get(0).as_deref(), Some("foo"));
270        assert_eq!(args.get(1).as_deref(), Some("bar"));
271        assert_eq!(args.get(2).as_deref(), Some("baz"));
272        assert_eq!(args.get(3), None);
273    }
274
275    #[test]
276    fn get_keeps_commas_inside_quoted_arguments() {
277        let args = Args { raw: "i,\"ciao, sono antonio\",secret" };
278
279        assert_eq!(args.get(0).as_deref(), Some("i"));
280        assert_eq!(args.get(1).as_deref(), Some("ciao, sono antonio"));
281        assert_eq!(args.get(2).as_deref(), Some("secret"));
282    }
283
284    #[test]
285    fn get_decodes_escaped_quotes() {
286        let args = Args { raw: r#"i,"ciao, sono \"antonio\"",mysecretpassword"# };
287
288        assert_eq!(args.get_raw(1), Some(r#"ciao, sono \"antonio\""#));
289        assert_eq!(args.get(1).as_deref(), Some("ciao, sono \"antonio\""));
290        assert_eq!(args.get(2).as_deref(), Some("mysecretpassword"));
291    }
292
293    #[test]
294    fn get_handles_empty_arguments() {
295        let args = Args { raw: "first,,\"\",last" };
296
297        assert_eq!(args.get(0).as_deref(), Some("first"));
298        assert_eq!(args.get(1).as_deref(), Some(""));
299        assert_eq!(args.get(2).as_deref(), Some(""));
300        assert_eq!(args.get(3).as_deref(), Some("last"));
301    }
302}
303
304
305/// Wraps a value in double-quote characters (`"`).
306///
307/// Expands to a string literal `"\"<value>\""` suitable for use inside
308/// [`at_response!`] arguments when the protocol
309/// requires quoted strings.
310///
311/// # Syntax
312///
313/// ```rust,ignore
314/// at_quoted!(value)
315/// ```
316///
317/// # Examples
318///
319/// ```rust,no_run
320/// use at_parser_rs::at_quoted;
321///
322/// let q = at_quoted!("hello");   // → `"hello"`
323/// let q = at_quoted!(42);        // → `"42"`
324/// ```
325///
326/// Inside an AT response:
327///
328/// ```rust,no_run
329/// use at_parser_rs::{at_response, at_quoted};
330///
331/// const SIZE: usize = 64;
332/// let name = "world";
333/// let resp = at_response!(SIZE, "+CMD: "; at_quoted!(name));
334/// // resp contains: +CMD: "world"
335/// ```
336#[macro_export]
337macro_rules! at_quoted {
338    ($val:expr) => {
339        ::core::format_args!("\"{}\"", $val)
340    };
341}
342
343/// Macro to format an AT response with 1–6 comma-separated parameters.
344///
345/// Constructs an [`osal_rs::utils::Bytes`] buffer by formatting the given
346/// prefix string (`AT_RESP`) followed by the arguments separated by commas.
347///
348/// # Syntax
349///
350/// ```rust,ignore
351/// at_response!(SIZE, AT_RESP; arg1, arg2, ..., arg6)
352/// ```
353///
354/// - `SIZE` — `const usize` for the response buffer capacity (must match the
355///   capacity used by the surrounding [`AtContext`](crate::context::AtContext) impl)
356/// - `AT_RESP` — the AT response prefix string literal (e.g. `"+ECHO: "`)
357/// - `arg1..arg6` — values to append, comma-separated; any type implementing
358///   [`core::fmt::Display`] is accepted, including [`at_quoted!`] expressions
359///
360/// # Examples
361///
362/// ```rust,no_run
363/// use at_parser_rs::at_response;
364///
365/// const SIZE: usize = 64;
366///
367/// // Single boolean argument
368/// let resp = at_response!(SIZE, "+ECHO: "; 1u8);
369/// // buffer: "+ECHO: 1"
370///
371/// // Two arguments (state and brightness)
372/// let resp = at_response!(SIZE, "+LED: "; 1u8, 75u8);
373/// // buffer: "+LED: 1,75"
374///
375/// // Three arguments
376/// let resp = at_response!(SIZE, "+NET: "; "192.168.1.1", 8080u16, 1u8);
377/// // buffer: "+NET: 192.168.1.1,8080,1"
378/// ```
379///
380/// Using [`at_quoted!`] inside the response:
381///
382/// ```rust,no_run
383/// use at_parser_rs::{at_response, at_quoted};
384///
385/// const SIZE: usize = 64;
386/// let ssid = "MyNetwork";
387/// let resp = at_response!(SIZE, "+WIFI: "; at_quoted!(ssid), -70i8);
388/// // buffer: +WIFI: "MyNetwork",-70
389/// ```
390#[macro_export]
391macro_rules! at_response {
392    ($size:expr, $at_resp:expr; $a1:expr) => {{
393        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
394        response.format(format_args!("{}", $a1));
395        ($at_resp, response)
396    }};
397    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr) => {{
398        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
399        response.format(format_args!("{},{}", $a1, $a2));
400        ($at_resp, response)
401    }};
402    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr, $a3:expr) => {{
403        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
404        response.format(format_args!("{},{},{}", $a1, $a2, $a3));
405        ($at_resp, response)
406    }};
407    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr, $a3:expr, $a4:expr) => {{
408        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
409        response.format(format_args!("{},{},{},{}", $a1, $a2, $a3, $a4));
410        ($at_resp, response)
411    }};
412    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr, $a3:expr, $a4:expr, $a5:expr) => {{
413        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
414        response.format(format_args!("{},{},{},{},{}", $a1, $a2, $a3, $a4, $a5));
415        ($at_resp, response)
416    }};
417    ($size:expr, $at_resp:expr; $a1:expr, $a2:expr, $a3:expr, $a4:expr, $a5:expr, $a6:expr) => {{
418        let mut response = osal_rs::utils::Bytes::<{$size}>::new();
419        response.format(format_args!("{},{},{},{},{},{}", $a1, $a2, $a3, $a4, $a5, $a6));
420        ($at_resp, response)
421    }};
422}
423
424
425
426/// Declares a static `COMMANDS` table mapping AT command strings to their handlers.
427///
428/// This macro expands into a `static COMMANDS` binding of type
429/// `&[(&'static str, &mut dyn AtContext<SIZE>)]`, which can then be passed to
430/// [`AtParser::set_commands`](crate::parser::AtParser::set_commands).
431///
432/// # Syntax
433///
434/// ```rust,ignore
435/// at_modules! {
436///     SIZE;
437///     ("AT+CMD1", "+CMD1: ") => HANDLER1,
438///     ("AT+CMD2", "+CMD2: ") => HANDLER2,
439/// }
440/// ```
441///
442/// - `SIZE` — `const usize` that defines the response buffer capacity (must match the
443///   capacity used by [`AtParser`](crate::parser::AtParser) and every [`AtContext`](crate::context::AtContext) impl).
444/// - `"AT+CMD"` — the AT command string the parser will match against the input.
445/// - `"+CMD: "` — the AT response prefix forwarded to every handler method.
446/// - `HANDLER` — a `static mut` variable that implements [`AtContext<SIZE>`](crate::context::AtContext).
447///
448/// # Safety
449///
450/// The macro uses an `unsafe` block internally to obtain `&mut` references to
451/// `static mut` items.  It is the caller's responsibility to ensure:
452///
453/// - **Single-threaded access only** — do not call this in a multi-threaded or
454///   RTOS context without external synchronisation.
455/// - **One call site** — the generated `COMMANDS` symbol is `static`; defining it
456///   more than once in the same scope will cause a compile error.
457///
458/// # Limitations
459///
460/// - All handlers must implement `AtContext` with the **same** `SIZE` constant.
461/// - The generated symbol is always named `COMMANDS`; rename it after expansion
462///   if you need multiple tables.
463///
464/// # Example — basic usage
465///
466/// ```rust,no_run
467/// use at_parser_rs::at_modules;
468/// use at_parser_rs::context::AtContext;
469/// use at_parser_rs::{Args, AtResult, AtError, at_response};
470///
471/// const SIZE: usize = 64;
472///
473/// struct EchoModule { echo: bool }
474/// impl AtContext<SIZE> for EchoModule {
475///     fn query(&mut self, at_response: &'static str) -> AtResult<SIZE> {
476///         Ok(at_response!(SIZE, at_response; if self.echo { 1u8 } else { 0u8 }))
477///     }
478///     fn set(&mut self, at_response: &'static str, args: Args) -> AtResult<SIZE> {
479///         let value = args.get(0).ok_or((at_response, AtError::InvalidArgs))?;
480///         match value.as_ref() {
481///             "0" => { self.echo = false; Ok(at_response!(SIZE, at_response; "OK")) }
482///             "1" => { self.echo = true;  Ok(at_response!(SIZE, at_response; "OK")) }
483///             _ => Err((at_response, AtError::InvalidArgs)),
484///         }
485///     }
486/// }
487///
488/// struct ResetModule;
489/// impl AtContext<SIZE> for ResetModule {
490///     fn exec(&mut self, at_response: &'static str) -> AtResult<SIZE> {
491///         Ok(at_response!(SIZE, at_response; "OK"))
492///     }
493/// }
494///
495/// static mut ECHO:  EchoModule  = EchoModule { echo: false };
496/// static mut RESET: ResetModule = ResetModule;
497///
498/// at_modules! {
499///     SIZE;
500///     ("AT+ECHO", "+ECHO: ") => ECHO,
501///     ("AT+RST",  "+RST: ")  => RESET,
502/// }
503///
504/// // COMMANDS is now available in scope:
505/// // parser.set_commands(COMMANDS);
506/// ```
507///
508/// # Example — single handler
509///
510/// ```rust,no_run
511/// use at_parser_rs::at_modules;
512/// use at_parser_rs::context::AtContext;
513/// use at_parser_rs::{AtResult, at_response};
514///
515/// const SIZE: usize = 32;
516///
517/// struct PingModule;
518/// impl AtContext<SIZE> for PingModule {
519///     fn exec(&mut self, at_response: &'static str) -> AtResult<SIZE> {
520///         Ok(at_response!(SIZE, at_response; "PONG"))
521///     }
522/// }
523///
524/// static mut PING: PingModule = PingModule;
525///
526/// at_modules! {
527///     SIZE;
528///     ("AT+PING", "+PING: ") => PING,
529/// }
530/// ```
531///
532/// # Recommended alternative
533///
534/// For multi-type handler tables or when `static mut` is undesirable, prefer the
535/// explicit slice approach — it requires no `unsafe` at the call site and allows
536/// mixing handler types via trait objects:
537///
538/// ```rust,no_run
539/// use at_parser_rs::context::AtContext;
540///
541/// const SIZE: usize = 64;
542///
543/// let mut echo  = EchoModule { echo: false };
544/// let mut reset = ResetModule;
545///
546/// let commands: &mut [(&str, &str, &mut dyn AtContext<SIZE>)] = &mut [
547///     ("AT+ECHO", "+ECHO: ", &mut echo),
548///     ("AT+RST",  "+RST: ",  &mut reset),
549/// ];
550/// parser.set_commands(commands);
551/// ```
552#[macro_export]
553macro_rules! at_modules {
554    (
555        $size:expr;
556        $( ($name:expr, $at_resp:expr) => $module:ident ),* $(,)?
557    ) => {
558        static COMMANDS: &mut [(&'static str, &'static str, &mut dyn AtContext<$size>)] = unsafe {
559            &mut [
560                $(
561                    ($name, $at_resp, &mut $module),
562                )*
563            ]
564        };
565    };
566}