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:
UART2 bytes
-> line assembler
-> AT tokenizer/parser
-> command response matcher
-> URC dispatcher
-> modem state machine3. UART is a stream, not messages
Do not assume that one uart_read_bytes() call returns one AT line. One call may return:
"AT\r\r\nO"and the next:
"K\r\n+QMTSTAT: 0,3\r\n"The required approach is:
uart_read_bytes()
-> feed bytes one by one
-> line buffer
-> when CR/LF completed: parse full lineIncorrect:
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:
ESP32 -> AT+QMTOPEN=0,"broker",1883
EC25 -> OK
EC25 -> +QMTOPEN: 0,0But the sequence may also be:
ESP32 -> AT+QMTOPEN=0,"broker",1883
EC25 -> +CEREG: 1
EC25 -> OK
EC25 -> +QMTOPEN: 0,0The parser must distinguish:
terminal response:
OK
ERROR
+CME ERROR
+CMS ERROR
intermediate response:
+CSQ
+CPIN
+QIACT
URC:
+QMTOPEN
+QMTCONN
+QMTSTAT
+QIURC
+CEREG5. AT parser layers
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:
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.
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.
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
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
#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
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
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:
AT+QMTPUBEX=...
>
payloadCorrect:
state = WAIT_PUBLISH_PROMPT
if MODEM_EV_PROMPT arrives:
send payload
state = WAIT_PUBLISH_RESULT
if timeout:
recovery11. 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:
# 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:
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:
ECHO: AT+QMTOPEN=...
OK
QMTOPEN client=0 result=0
CEREG registered
QMTSTAT client=0 result=313. A short recap
UART event queue
-> read bytes
-> line assembler
-> line classifier
-> parse OK/ERROR/CME/CMS
-> parse URC
-> emit modem_event_t
-> modem state machineRules:
- UART is a byte stream, not ready-made messages.
- One task reads one UART.
- A URC may arrive at any moment.
- OK does not always mean final success.
- Overflow invalidates the current reply.
- The parser does not perform recovery.
- The state machine decides what happens next.
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.