1. Тема дня

UART-парсер для AT-модема Quectel EC25: поток байтов, сборка строк, отделение ответов на команды от URC, обработка OK, ERROR, +CME ERROR, +QMTOPEN, +QMTCONN, +QMTSTAT, переполнение буфера и частичные сообщения. Главная мысль: AT-парсер не должен “ждать строку OK”. Он должен непрерывно разбирать поток, в котором ответы на команды и асинхронные URC могут перемешиваться.

2. Зачем это нужно

Типичные проблемы:

  • URC пришёл во время ожидания ответа;
  • строка пришла кусками;
  • в буфер попало echo команды;
  • OK пришёл раньше финального MQTT-события;
  • UART RX buffer переполнился;
  • debug-команда вклинилась между рабочими AT-командами;
  • state machine решила, что модем завис, хотя парсер потерял строку.

Правильный pipeline:

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

3. UART - поток, а не сообщения

Нельзя думать, что один uart_read_bytes() вернёт одну AT-строку. Вызов может вернуть:

text
"AT\r\r\nO"

следующий:

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

Поэтому нужно:

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

Плохо:

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

4. URC ломают простой парсер

URC - unsolicited result code. Модем отправляет его самостоятельно. Пример:

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

Но может быть:

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

Парсер должен различать:

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

5. Слои AT-парсера

text
1. UART event layer
   Читает байты из ESP-IDF UART driver.
2. Line assembler
   Собирает строки по CR/LF, защищается от overflow.
3. AT line classifier
   Определяет тип строки: OK, ERROR, CME, URC, prompt, echo.
4. Modem event dispatcher
   Превращает строку в modem_event_t.

6. UART event layer

Каркас:

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));
}

Обработка событий:

Размер UART_DATA относится ко всему событию. Читаем его ограниченными порциями, уменьшая remaining на фактически полученное число байт. При нулевом или отрицательном результате не крутимся бесконечно: текущая AT-транзакция становится недостоверной. У UART один владелец; overflow/reset обрабатываются отдельно. Очереди и период обслуживания должны оставаться ограниченными по времени.

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;
}

В режиме publish приглашение > разрешает ввод payload; ждать следующего CR/LF рискованно. Распознавайте его на уровне байтов только тогда, когда state machine ожидает publish prompt, текущая строка пуста и parser не находится в overflow/desync. Следующий необязательный пробел отделяется от нового ответа. Это не бинарный parser QMTRECV: длины payload и data mode требуют отдельного состояния.

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, буфер и чтение событий

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;
        }
    }
}

Если был overflow, часть строки могла потеряться. Текущий ответ на команду уже подозрителен.

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]);
    }
}

Нельзя делать только strtok(buf, "\r\n"), потому что строка может прийти частями.

8. Классификация строк

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;
}

Whitelist лучше, чем “всё с плюсом - URC”, потому что ответы на команды тоже начинаются с +.

10. Prompt >

Некоторые команды имеют двухфазный ввод:

text
AT+QMTPUBEX=...
>
payload

Правильно:

text
state = WAIT_PUBLISH_PROMPT
если пришёл MODEM_EV_PROMPT:
    отправить payload
    state = WAIT_PUBLISH_RESULT
если timeout:
    recovery

11. Типичные ошибки

  • искать OK через strstr() в куске UART-буфера;
  • считать OK полным успехом операции;
  • не обрабатывать UART_FIFO_OVF и UART_BUFFER_FULL;
  • читать один UART из нескольких задач;
  • делать парсер слишком “умным”, чтобы он сам делал recovery;
  • логировать каждую строку всегда.

12. Практическое задание

Создай 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.

Мини-тест без модема:

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]));
    }
}

Ожидаемые события:

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

13. Короткий итог

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

Правила:

  1. UART - поток байтов, а не готовые сообщения.
  2. Один UART читает одна задача.
  3. URC может прийти в любой момент.
  4. OK не всегда финальный успех.
  5. Overflow invalidates текущий ответ.
  6. Парсер не делает recovery.
  7. State machine решает, что делать дальше.
Один uart_read_bytes() заканчивается O, следующий начинается K и CR/LF. Как parser должен распознать OK?

Задание

Строка превысила bounded line buffer до CR/LF. Почему нельзя разобрать только сохранённый prefix? Опишите bounded resynchronisation policy. Это редакционная самопроверка, не утверждение, что каждый source snippet её уже реализует.

Критерии самопроверки: Отклоните truncated input, пропустите до delimiter и сообщите о потере без modem recovery внутри parser.

Показать ответ автора

Обрезанный prefix может выглядеть valid reply, скрывая потерю данных. Пометьте текущий response invalid, перейдите в dropping/resynchronisation до delimiter, затем начните новую строку и передайте overflow event state machine. Остаток bytes не должен выглядеть новым успешным reply.