1. Today's topic

A UART parser for the Quectel EC25 AT modem: byte streams, line assembly, separating command replies from URCs, handling OK, ERROR, +CME ERROR, +QMTOPEN, +QMTCONN and +QMTSTAT, buffer overflow and partial messages. The main idea: an AT parser must not just “wait for an OK line”. It must continuously parse a stream in which command replies and asynchronous URCs can be interleaved.

2. Why this matters

Typical problems:

  • A URC arrives while waiting for a reply;
  • a line arrives in pieces;
  • the buffer contains a command echo;
  • OK arrives before the final MQTT event;
  • the UART RX buffer overflows;
  • a debug command is inserted between operational AT commands;
  • the state machine decides that the modem has hung although the parser lost a line.

The correct pipeline:

text
UART2 bytes
  -> line assembler
  -> AT tokenizer/parser
  -> command response matcher
  -> URC dispatcher
  -> modem state machine

3. UART is a stream, not messages

Do not assume that one uart_read_bytes() call returns one AT line. One call may return:

text
"AT\r\r\nO"

and the next:

text
"K\r\n+QMTSTAT: 0,3\r\n"

The required approach is:

text
uart_read_bytes()
  -> feed bytes one by one
  -> line buffer
  -> when CR/LF completed: parse full line

Incorrect:

c
int len = uart_read_bytes(UART_NUM_2, buf, sizeof(buf), timeout);
if (strstr((char *)buf, "OK")) {
    got_ok = true;
}

4. URCs break a simple parser

URC means unsolicited result code. The modem sends it on its own. For example:

text
ESP32 -> AT+QMTOPEN=0,"broker",1883
EC25  -> OK
EC25  -> +QMTOPEN: 0,0

But the sequence may also be:

text
ESP32 -> AT+QMTOPEN=0,"broker",1883
EC25  -> +CEREG: 1
EC25  -> OK
EC25  -> +QMTOPEN: 0,0

The parser must distinguish:

text
terminal response:
  OK
  ERROR
  +CME ERROR
  +CMS ERROR
intermediate response:
  +CSQ
  +CPIN
  +QIACT
URC:
  +QMTOPEN
  +QMTCONN
  +QMTSTAT
  +QIURC
  +CEREG

5. AT parser layers

text
1. UART event layer
   Reads bytes from the ESP-IDF UART driver.
2. Line assembler
   Assembles lines using CR/LF and guards against overflow.
3. AT line classifier
   Identifies line type: OK, ERROR, CME, URC, prompt, echo.
4. Modem event dispatcher
   Converts the line into modem_event_t.

6. UART event layer

A skeleton:

c
static QueueHandle_t modem_uart_queue;
void modem_uart_init(void)
{
    const int rx_buf_size = 4096;
    const int tx_buf_size = 2048;
    const int event_queue_size = 20;
    uart_config_t cfg = {
        .baud_rate = 115200,
        .data_bits = UART_DATA_8_BITS,
        .parity    = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
        .source_clk = UART_SCLK_DEFAULT,
    };
    ESP_ERROR_CHECK(uart_driver_install(
        UART_NUM_2,
        rx_buf_size,
        tx_buf_size,
        event_queue_size,
        &modem_uart_queue,
        0
    ));
    ESP_ERROR_CHECK(uart_param_config(UART_NUM_2, &cfg));
    ESP_ERROR_CHECK(uart_set_pin(UART_NUM_2, 17, 16,
                                 UART_PIN_NO_CHANGE,
                                 UART_PIN_NO_CHANGE));
}

Event handling:

UART_DATA describes the entire event. Read it in bounded chunks and reduce remaining by the number actually received. A zero or negative result must not cause an infinite loop: the current AT transaction becomes untrustworthy. Give the UART one owner and handle overflow/reset separately. Bound queues and service time as well.

c
static bool uart_data_event_drain(size_t event_bytes)
{
    uint8_t buf[256];
    size_t remaining = event_bytes;
    while (remaining != 0U) {
        size_t request = remaining < sizeof(buf) ? remaining : sizeof(buf);
        int received = uart_read_bytes(UART_NUM_2, buf, request, 0);
        if (received <= 0) {
            /* A short/error read invalidates the current transaction. */
            modem_emit_event(MODEM_EV_UART_DESYNC);
            return false;
        }
        at_parser_feed(buf, (size_t)received);
        remaining -= (size_t)received;
    }
    return true;
}

In publish data mode, > permits payload input; waiting for a following CR/LF is unsafe. Recognize it in the byte assembler only while the state machine expects a publish prompt, the current line is empty, and the parser is not in overflow/desync. Consume the optional following space separately. This is not a binary QMTRECV parser: payload lengths and data mode require their own state.

c
static bool s_expect_publish_prompt;
static bool s_skip_prompt_space;

/* Call after submitting the publish command; clear on timeout/recovery. */
static void at_parser_expect_publish_prompt(bool expected)
{
    s_expect_publish_prompt = expected;
    s_skip_prompt_space = false;
}

/* Run at the start of the byte assembler, before CR/LF processing. */
static bool at_parser_consume_prompt_byte(uint8_t b, size_t line_length)
{
    if (s_skip_prompt_space) {
        s_skip_prompt_space = false;
        if (b == ' ') return true;
    }
    if (s_expect_publish_prompt && line_length == 0U && b == '>') {
        s_expect_publish_prompt = false;
        s_skip_prompt_space = true;
        modem_emit_event(MODEM_EV_PROMPT);
        return true;
    }
    return false;
}

ESP-IDF: UART buffering and event reads

Quectel: MQTT data-mode publish

c
static void modem_uart_task(void *arg)
{
    uart_event_t event;
    uint8_t buf[256];
    while (1) {
        if (xQueueReceive(modem_uart_queue, &event, portMAX_DELAY) != pdTRUE) {
            continue;
        }
        switch (event.type) {
        case UART_DATA: {
            int len = uart_read_bytes(UART_NUM_2,
                                      buf,
                                      MIN(event.size, sizeof(buf)),
                                      0);
            if (len > 0) {
                at_parser_feed(buf, len);
            }
            break;
        }
        case UART_FIFO_OVF:
            diag.uart_fifo_ovf++;
            uart_flush_input(UART_NUM_2);
            xQueueReset(modem_uart_queue);
            at_parser_reset_line();
            modem_emit_event(MODEM_EV_UART_OVERFLOW);
            break;
        case UART_BUFFER_FULL:
            diag.uart_buffer_full++;
            uart_flush_input(UART_NUM_2);
            xQueueReset(modem_uart_queue);
            at_parser_reset_line();
            modem_emit_event(MODEM_EV_UART_OVERFLOW);
            break;
        default:
            break;
        }
    }
}

After overflow, part of a line may have been lost. The current command reply is already suspect.

7. Line assembler

c
#define AT_LINE_MAX 256
typedef struct {
    char line[AT_LINE_MAX];
    size_t len;
    bool overflow;
} at_line_assembler_t;
static at_line_assembler_t s_at_line;
static void at_parser_feed_byte(uint8_t b)
{
    if (b == '\r' || b == '\n') {
        if (s_at_line.len > 0) {
            s_at_line.line[s_at_line.len] = '\0';
            if (!s_at_line.overflow) {
                at_parser_handle_line(s_at_line.line);
            } else {
                diag.at_line_overflow++;
                modem_emit_event(MODEM_EV_PARSE_OVERFLOW);
            }
            s_at_line.len = 0;
            s_at_line.overflow = false;
        }
        return;
    }
    if (s_at_line.len + 1 < AT_LINE_MAX) {
        s_at_line.line[s_at_line.len++] = (char)b;
    } else {
        s_at_line.overflow = true;
    }
}
void at_parser_feed(const uint8_t *data, size_t len)
{
    for (size_t i = 0; i < len; i++) {
        at_parser_feed_byte(data[i]);
    }
}

Using only strtok(buf, "\r\n") is not enough because a line may arrive in fragments.

8. Line classification

c
typedef enum {
    AT_LINE_EMPTY = 0,
    AT_LINE_ECHO,
    AT_LINE_OK,
    AT_LINE_ERROR,
    AT_LINE_CME_ERROR,
    AT_LINE_CMS_ERROR,
    AT_LINE_PROMPT,
    AT_LINE_URC,
    AT_LINE_RESPONSE,
    AT_LINE_UNKNOWN,
} at_line_type_t;
static at_line_type_t at_classify_line(const char *line)
{
    if (line[0] == '\0') return AT_LINE_EMPTY;
    if (strcmp(line, "OK") == 0) return AT_LINE_OK;
    if (strcmp(line, "ERROR") == 0) return AT_LINE_ERROR;
    if (strncmp(line, "+CME ERROR:", 11) == 0) return AT_LINE_CME_ERROR;
    if (strncmp(line, "+CMS ERROR:", 11) == 0) return AT_LINE_CMS_ERROR;
    if (strcmp(line, ">") == 0 || strcmp(line, "> ") == 0) return AT_LINE_PROMPT;
    if (strncmp(line, "AT", 2) == 0) return AT_LINE_ECHO;
    if (at_is_known_urc(line)) return AT_LINE_URC;
    if (line[0] == '+') return AT_LINE_RESPONSE;
    return AT_LINE_UNKNOWN;
}

9. URC whitelist

c
static bool at_is_known_urc(const char *line)
{
    return
        strncmp(line, "+QMTOPEN:", 9) == 0 ||
        strncmp(line, "+QMTCONN:", 9) == 0 ||
        strncmp(line, "+QMTSTAT:", 9) == 0 ||
        strncmp(line, "+QMTRECV:", 9) == 0 ||
        strncmp(line, "+QIURC:", 7) == 0 ||
        strncmp(line, "+CEREG:", 7) == 0 ||
        strncmp(line, "+CREG:", 6) == 0 ||
        strncmp(line, "+CMTI:", 6) == 0 ||
        strcmp(line, "RING") == 0;
}

A whitelist is better than treating “everything starting with a plus sign” as a URC, because command replies also start with +.

10. Prompt >

Some commands have two-phase input:

text
AT+QMTPUBEX=...
>
payload

Correct:

text
state = WAIT_PUBLISH_PROMPT
if MODEM_EV_PROMPT arrives:
    send payload
    state = WAIT_PUBLISH_RESULT
if timeout:
    recovery

11. Common mistakes

  • Searching for OK with strstr() in a UART buffer fragment;
  • treating OK as complete operation success;
  • not handling UART_FIFO_OVF and UART_BUFFER_FULL;
  • reading one UART from several tasks;
  • making the parser too “smart” so it performs recovery itself;
  • always logging every line.

12. Practical task

Create AT_PARSER.md:

markdown
# AT parser architecture
UART2 is owned only by modem_uart_task.
Pipeline:
1. ESP-IDF UART event queue
2. uart_read_bytes()
3. byte feed
4. line assembler
5. line classifier
6. command response parser
7. URC parser
8. modem_event_queue
9. modem_state_machine
Parser rules:
- UART is a byte stream, not message packets.
- `OK`/`ERROR` are terminal responses, but not always final operation result.
- URC may arrive at any time.
- Parser never performs recovery itself.
- Parser emits modem_event_t.
- UART overflow invalidates current command response.

A small test without a modem:

c
static void test_at_parser_feed_chunks(void)
{
    const char *chunks[] = {
        "AT+QMTOPEN=0,\"broker\",1883\r",
        "\r\nOK\r\n+QMT",
        "OPEN: 0,0\r\n+CEREG: 1\r\n",
        "+QMTSTAT: 0,3\r\n"
    };
    for (size_t i = 0; i < sizeof(chunks) / sizeof(chunks[0]); i++) {
        at_parser_feed((const uint8_t *)chunks[i], strlen(chunks[i]));
    }
}

Expected events:

text
ECHO: AT+QMTOPEN=...
OK
QMTOPEN client=0 result=0
CEREG registered
QMTSTAT client=0 result=3

13. A short recap

text
UART event queue
  -> read bytes
  -> line assembler
  -> line classifier
  -> parse OK/ERROR/CME/CMS
  -> parse URC
  -> emit modem_event_t
  -> modem state machine

Rules:

  1. UART is a byte stream, not ready-made messages.
  2. One task reads one UART.
  3. A URC may arrive at any moment.
  4. OK does not always mean final success.
  5. Overflow invalidates the current reply.
  6. The parser does not perform recovery.
  7. The state machine decides what happens next.
One uart_read_bytes() call ends with O and the next starts with K followed by CR/LF. How should the parser recognise OK?

Exercise

A received line exceeds the bounded line buffer before CR/LF. Explain why parsing only its stored prefix is unsafe and describe a bounded resynchronisation policy. This policy is an editorial self-check, not a claim that every source snippet already implements it.

Self-check criteria: Reject truncated input, consume to a delimiter and report loss without performing modem recovery inside the parser.

Show the supplied answer

A truncated prefix may resemble a valid reply while hiding missing data. Mark the current response invalid, enter a dropping/resynchronisation state through the line delimiter, then start a fresh line and report the overflow event to the state machine. Do not let leftover bytes masquerade as a new successful reply.